fix(catalog): clamp still/poster/logo backdrops to largest cached variant

Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-28 17:11:11 -04:00
co-authored by Claude Opus 4.7
parent 0d651cc947
commit 772569cefe
5 changed files with 139 additions and 18 deletions
+4 -9
View File
@@ -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
+1 -9
View File
@@ -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)
}
+12
View File
@@ -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,
+37
View File
@@ -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"
+85
View File
@@ -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)
}
})
}
}