From 9df38c95a372af76461874c363c9a2655286c64e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 11:54:29 +0200 Subject: [PATCH] fix(library): classify resolution by width + height, not height alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/cmd/scan.go | 2 +- internal/library/resolve.go | 43 ++++++++++++++++++++++++++++++-- internal/library/resolve_test.go | 35 ++++++++++++++------------ internal/library/sync.go | 2 +- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index df66a18..d05ae29 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -241,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) { continue } - res := library.ResolveResolution(item.MediaInfo.Video.Height) + res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height) if res == "" { res = "other" } diff --git a/internal/library/resolve.go b/internal/library/resolve.go index 531fa3b..b9c16db 100644 --- a/internal/library/resolve.go +++ b/internal/library/resolve.go @@ -13,8 +13,17 @@ var ( altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) ) -// ResolveResolution maps a pixel height to a standard resolution label. -func ResolveResolution(height int) string { +// ResolveResolution maps video dimensions to a standard resolution label. +// 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 { case height >= 2000: 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. func DeriveContentType(item LibraryItem) string { if item.Season > 0 || item.Episode > 0 { diff --git a/internal/library/resolve_test.go b/internal/library/resolve_test.go index c226e06..881768e 100644 --- a/internal/library/resolve_test.go +++ b/internal/library/resolve_test.go @@ -8,28 +8,31 @@ import ( func TestResolveResolution(t *testing.T) { tests := []struct { + name string + width int height int want string }{ - {2160, "2160p"}, - {2000, "2160p"}, - {1080, "1080p"}, - {1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080 - {900, "1080p"}, - {720, "720p"}, - {600, "720p"}, - {576, "480p"}, - {480, "480p"}, - {400, "480p"}, - {360, ""}, - {0, ""}, + {"4K square", 3840, 2160, "2160p"}, + {"4K low height", 3840, 1600, "2160p"}, + {"1080p square", 1920, 1080, "1080p"}, + {"1080p cinematic 2.39:1", 1920, 804, "1080p"}, // anamorphic widescreen — must not fall to 720p + {"1080p cinematic 2.35:1", 1920, 818, "1080p"}, + {"1080p 21:9", 2560, 1080, "1080p"}, + {"720p square", 1280, 720, "720p"}, + {"720p widescreen", 1280, 540, "720p"}, + {"480p", 854, 480, "480p"}, + {"sub-480", 640, 360, ""}, + {"zero", 0, 0, ""}, } for _, tt := range tests { - got := ResolveResolution(tt.height) - if got != tt.want { - t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want) - } + t.Run(tt.name, func(t *testing.T) { + got := ResolveResolution(tt.width, tt.height) + if got != tt.want { + t.Errorf("ResolveResolution(%d, %d) = %q, want %q", tt.width, tt.height, got, tt.want) + } + }) } } diff --git a/internal/library/sync.go b/internal/library/sync.go index bafd054..f3cd9e6 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -23,7 +23,7 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem { if item.MediaInfo != 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.HDR = item.MediaInfo.Video.HDR si.BitDepth = item.MediaInfo.Video.BitDepth