diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index ffa4ffe5..e99f0c51 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -996,16 +996,11 @@ func featuredPosterPath(path string) string { } // featuredBackdropPath converts an S3 backdrop path from original to w1920 for -// featured/hero contexts (displayed at full viewport width). -// Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. +// featured/hero contexts (displayed at full viewport width). Episode stills +// used as backdrops lack a w1920 variant and clamp to their largest cached +// size. Full URLs (TMDB/TVDB) and plugin-prefixed paths are returned as-is. func featuredBackdropPath(path string) string { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - if strings.Contains(path, "://") { - return path - } - return strings.Replace(path, "/original.", "/w1920.", 1) + return catalog.BackdropVariantPath(path, "w1920") } // presignURL resolves an image path to a usable URL, delegating to the diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 5ae1cb9e..3441b026 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -1359,15 +1359,7 @@ func (h *SectionHandler) toSectionItemResponse(sectionType sections.SectionType, // uses the cached w1280 backdrop for Continue Watching / Next Up rows. func sectionBackdropPath(sectionType sections.SectionType, path string) string { if sectionType == sections.SectionContinueWatching || sectionType == sections.SectionNextUp { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - // Plugin-prefixed paths pass through; the resolver still receives the - // existing "featured" semantic variant. - if strings.Contains(path, "://") { - return path - } - return strings.Replace(path, "/original.", "/w1280.", 1) + return catalog.BackdropVariantPath(path, "w1280") } return featuredBackdropPath(path) } diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index a46f5d2b..982e565c 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -52,6 +52,18 @@ func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { path: "/tmdb/shows/1399/backdrop/original.jpg", want: "/tmdb/shows/1399/backdrop/w1920.jpg", }, + { + name: "continue watching episode still clamps to w500", + sectionType: sections.SectionContinueWatching, + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "featured section episode still clamps to w500", + sectionType: sections.SectionRecentlyAdded, + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, { name: "http paths pass through", sectionType: sections.SectionContinueWatching, diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index c4f3c49d..5f628643 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -2189,6 +2189,43 @@ func cachedImageVariantPath(path, imageType, size string) string { return path } +// imageTypeFromCachedPath returns the image type segment ("poster", "backdrop", +// "logo", "still") encoded in a cached S3 image path of the form +// ".../{imageType}/{variant}.{ext}". It returns "" for full URLs, +// plugin-prefixed paths, or paths with no directory segment. +func imageTypeFromCachedPath(path string) string { + if path == "" || strings.Contains(path, "://") { + return "" + } + lastSlash := strings.LastIndex(path, "/") + if lastSlash <= 0 { + return "" + } + dir := path[:lastSlash] + return dir[strings.LastIndex(dir, "/")+1:] +} + +// BackdropVariantPath rewrites a cached "/original." image path to the +// requested backdrop variant (e.g. "w1280" or "w1920"). Episode "backdrops" +// are frequently the episode still, which the cache only generates at +// w500/w300 — so requesting a backdrop width 404s. For still/poster/logo +// paths this clamps to that type's largest cached variant instead. Full URLs, +// plugin-prefixed paths, and non-"/original." paths pass through unchanged. +func BackdropVariantPath(path, desiredVariant string) string { + if path == "" || strings.Contains(path, "://") || !strings.Contains(path, "/original.") { + return path + } + variant := desiredVariant + switch imageType := imageTypeFromCachedPath(path); imageType { + case "still", "poster", "logo": + variant = cachedImageVariantKey(imageType, "") + } + if variant == "" { + return path + } + return strings.Replace(path, "/original.", "/"+variant+".", 1) +} + func cachedImageVariantKey(imageType, size string) string { if size == "original" { return "original" diff --git a/internal/catalog/image_variants_test.go b/internal/catalog/image_variants_test.go new file mode 100644 index 00000000..300ae0af --- /dev/null +++ b/internal/catalog/image_variants_test.go @@ -0,0 +1,85 @@ +package catalog + +import "testing" + +func TestImageTypeFromCachedPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"episode still", "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", "still"}, + {"movie backdrop", "tmdb/movies/550/backdrop/original.webp", "backdrop"}, + {"leading slash poster", "/tmdb/movies/550/poster/original.jpg", "poster"}, + {"logo", "tmdb/movies/550/logo/original.png", "logo"}, + {"http url", "https://images.example.com/backdrop/original.jpg", ""}, + {"plugin path", "plugin://tmdb/backdrop/original.jpg", ""}, + {"no slash", "original.webp", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := imageTypeFromCachedPath(tt.path); got != tt.want { + t.Fatalf("imageTypeFromCachedPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestBackdropVariantPath(t *testing.T) { + tests := []struct { + name string + path string + desired string + want string + }{ + { + name: "real backdrop keeps requested w1280", + path: "tmdb/movies/550/backdrop/original.webp", + desired: "w1280", + want: "tmdb/movies/550/backdrop/w1280.webp", + }, + { + name: "real backdrop keeps requested w1920", + path: "/tmdb/shows/1399/backdrop/original.jpg", + desired: "w1920", + want: "/tmdb/shows/1399/backdrop/w1920.jpg", + }, + { + name: "episode still clamps to w500 (no large variant exists)", + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + desired: "w1280", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "episode still clamps w1920 to w500", + path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", + desired: "w1920", + want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", + }, + { + name: "http url passes through", + path: "https://images.example.com/backdrop/original.jpg", + desired: "w1280", + want: "https://images.example.com/backdrop/original.jpg", + }, + { + name: "plugin path passes through", + path: "plugin://tmdb/backdrop/original.jpg", + desired: "w1280", + want: "plugin://tmdb/backdrop/original.jpg", + }, + { + name: "path without original segment passes through", + path: "tmdb/movies/550/backdrop/w300.webp", + desired: "w1280", + want: "tmdb/movies/550/backdrop/w300.webp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BackdropVariantPath(tt.path, tt.desired); got != tt.want { + t.Fatalf("BackdropVariantPath(%q, %q) = %q, want %q", tt.path, tt.desired, got, tt.want) + } + }) + } +}