fix(library): classify resolution by width + height, not height alone

Cinematic widescreen content (1920×804 at 2.39:1, 3840×1600 21:9, etc.)
was being misclassified: a 1080p source presented as 1920×804 fell to
720p because 804 < 900. Same shape for 2160p sources letterboxed below
2000px tall.

ResolveResolution now takes (width, height) and picks the larger of the
width-derived and height-derived buckets, so anamorphic/letterboxed
sources land in the right bucket.
This commit is contained in:
Deivid Soto 2026-05-27 11:54:29 +02:00
parent 0b2462c82a
commit 9df38c95a3
4 changed files with 62 additions and 20 deletions

View file

@ -241,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue continue
} }
res := library.ResolveResolution(item.MediaInfo.Video.Height) res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
if res == "" { if res == "" {
res = "other" res = "other"
} }

View file

@ -13,8 +13,17 @@ var (
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
) )
// ResolveResolution maps a pixel height to a standard resolution label. // ResolveResolution maps video dimensions to a standard resolution label.
func ResolveResolution(height int) string { // Uses both width and height so cinematic aspect ratios (2.35:1, 2.39:1, 21:9)
// are not misclassified — e.g. a 1080p source presented as 1920×804 letterboxed
// would fall to 720p if classified by height alone.
func ResolveResolution(width, height int) string {
byHeight := resolutionByHeight(height)
byWidth := resolutionByWidth(width)
return maxResolution(byHeight, byWidth)
}
func resolutionByHeight(height int) string {
switch { switch {
case height >= 2000: case height >= 2000:
return "2160p" return "2160p"
@ -29,6 +38,36 @@ func ResolveResolution(height int) string {
} }
} }
func resolutionByWidth(width int) string {
switch {
case width >= 3400:
return "2160p"
case width >= 1800:
return "1080p"
case width >= 1200:
return "720p"
case width >= 800:
return "480p"
default:
return ""
}
}
var resolutionRank = map[string]int{
"": 0,
"480p": 1,
"720p": 2,
"1080p": 3,
"2160p": 4,
}
func maxResolution(a, b string) string {
if resolutionRank[a] >= resolutionRank[b] {
return a
}
return b
}
// DeriveContentType guesses "movie" or "show" from parsed metadata. // DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string { func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 { if item.Season > 0 || item.Episode > 0 {

View file

@ -8,28 +8,31 @@ import (
func TestResolveResolution(t *testing.T) { func TestResolveResolution(t *testing.T) {
tests := []struct { tests := []struct {
name string
width int
height int height int
want string want string
}{ }{
{2160, "2160p"}, {"4K square", 3840, 2160, "2160p"},
{2000, "2160p"}, {"4K low height", 3840, 1600, "2160p"},
{1080, "1080p"}, {"1080p square", 1920, 1080, "1080p"},
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080 {"1080p cinematic 2.39:1", 1920, 804, "1080p"}, // anamorphic widescreen — must not fall to 720p
{900, "1080p"}, {"1080p cinematic 2.35:1", 1920, 818, "1080p"},
{720, "720p"}, {"1080p 21:9", 2560, 1080, "1080p"},
{600, "720p"}, {"720p square", 1280, 720, "720p"},
{576, "480p"}, {"720p widescreen", 1280, 540, "720p"},
{480, "480p"}, {"480p", 854, 480, "480p"},
{400, "480p"}, {"sub-480", 640, 360, ""},
{360, ""}, {"zero", 0, 0, ""},
{0, ""},
} }
for _, tt := range tests { for _, tt := range tests {
got := ResolveResolution(tt.height) t.Run(tt.name, func(t *testing.T) {
if got != tt.want { got := ResolveResolution(tt.width, tt.height)
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want) if got != tt.want {
} t.Errorf("ResolveResolution(%d, %d) = %q, want %q", tt.width, tt.height, got, tt.want)
}
})
} }
} }

View file

@ -23,7 +23,7 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
if item.MediaInfo != nil { if item.MediaInfo != nil {
if item.MediaInfo.Video != nil { if item.MediaInfo.Video != nil {
si.Resolution = ResolveResolution(item.MediaInfo.Video.Height) si.Resolution = ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
si.VideoCodec = item.MediaInfo.Video.Codec si.VideoCodec = item.MediaInfo.Video.Codec
si.HDR = item.MediaInfo.Video.HDR si.HDR = item.MediaInfo.Video.HDR
si.BitDepth = item.MediaInfo.Video.BitDepth si.BitDepth = item.MediaInfo.Video.BitDepth