From 562f97575e3484494904563ee6275edf79e7c3fe Mon Sep 17 00:00:00 2001 From: Silo Server Migration Date: Sun, 24 May 2026 18:37:46 -0400 Subject: [PATCH] feat(requests): add ListStudios/ListNetworks/ListGenres with logo cache --- internal/requests/discover_brand.go | 103 +++++++++++++++++++ internal/requests/service.go | 22 ++++- internal/requests/service_test.go | 147 +++++++++++++++++++++++++++- 3 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 internal/requests/discover_brand.go diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go new file mode 100644 index 00000000..dbc0d814 --- /dev/null +++ b/internal/requests/discover_brand.go @@ -0,0 +1,103 @@ +package requests + +import ( + "context" + "fmt" + "log/slog" +) + +// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. +// Studios and networks carry a TMDB ID and a brand color plus lazy-fetched logo. +// Genres carry no TMDB ID at this layer and render with a gradient and display +// name instead of a logo. +type DiscoverBrandCard struct { + TMDBID int `json:"tmdb_id,omitempty"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + GradientFrom string `json:"gradient_from,omitempty"` + GradientTo string `json:"gradient_to,omitempty"` + SeriesSupported bool `json:"series_supported,omitempty"` +} + +// ListStudios returns the bundled studios with lazily-fetched logo URLs. +// A failed logo lookup for any individual studio yields a card with LogoURL=nil; +// the response is never failed wholesale. +func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledStudios)) + for _, studio := range BundledStudios { + out = append(out, DiscoverBrandCard{ + TMDBID: studio.TMDBID, + Slug: studio.Slug, + DisplayName: studio.DisplayName, + BrandColor: studio.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + }) + } + return out, nil +} + +// ListNetworks returns the bundled TV networks with lazily-fetched logo URLs. +func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledNetworks)) + for _, network := range BundledNetworks { + out = append(out, DiscoverBrandCard{ + TMDBID: network.TMDBID, + Slug: network.Slug, + DisplayName: network.DisplayName, + BrandColor: network.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + }) + } + return out, nil +} + +// ListGenres returns the bundled genres. Each card carries gradient hints +// (no logo URL) and a SeriesSupported flag for the browse page to decide +// whether to show the Series tab. +func (s *Service) ListGenres(_ context.Context, _ Viewer) ([]DiscoverBrandCard, error) { + if s == nil { + return nil, fmt.Errorf("request service is not configured") + } + out := make([]DiscoverBrandCard, 0, len(BundledGenres)) + for _, genre := range BundledGenres { + out = append(out, DiscoverBrandCard{ + Slug: genre.Slug, + DisplayName: genre.DisplayName, + GradientFrom: genre.GradientFrom, + GradientTo: genre.GradientTo, + SeriesSupported: genre.SeriesID > 0, + }) + } + return out, nil +} + +// resolveLogoURL fetches the cached logo path and renders it as a TMDB image +// URL. It returns nil on lookup failure or when TMDB has no logo for the entity. +func (s *Service) resolveLogoURL(ctx context.Context, cache *logoCache, id int, kind, slug string) *string { + if cache == nil { + return nil + } + path, err := cache.Get(ctx, id) + if err != nil { + slog.Warn("requests: logo lookup failed", "kind", kind, "slug", slug, "id", id, "error", err) + return nil + } + if path == "" { + return nil + } + url := tmdbImageURL(path, "w300") + return &url +} + +// tmdbImageURL is the public TMDB image CDN URL for a given file path and size. +func tmdbImageURL(path, size string) string { + return "https://image.tmdb.org/t/p/" + size + path +} diff --git a/internal/requests/service.go b/internal/requests/service.go index 473aa272..0aff49a4 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -15,6 +15,9 @@ type TMDBClient interface { SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error) DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error) GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) + DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error) + GetCompany(ctx context.Context, id int) (*tmdb.Company, error) + GetNetwork(ctx context.Context, id int) (*tmdb.Network, error) } type TMDBExternalIDClient interface { @@ -56,6 +59,8 @@ type Service struct { secrets SecretResolver movieAdapter MovieFulfillmentAdapter seriesAdapter SeriesFulfillmentAdapter + companyLogos *logoCache + networkLogos *logoCache Now func() time.Time } @@ -69,12 +74,27 @@ type DiscoverySection struct { } func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { - return &Service{ + svc := &Service{ store: store, tmdb: tmdbClient, presence: presence, Now: func() time.Time { return time.Now().UTC() }, } + svc.companyLogos = newLogoCache(func(ctx context.Context, id int) (string, error) { + company, err := tmdbClient.GetCompany(ctx, id) + if err != nil { + return "", err + } + return company.LogoPath, nil + }, 24*time.Hour) + svc.networkLogos = newLogoCache(func(ctx context.Context, id int) (string, error) { + network, err := tmdbClient.GetNetwork(ctx, id) + if err != nil { + return "", err + } + return network.LogoPath, nil + }, 24*time.Hour) + return svc } func (s *Service) SetSecretResolver(resolver SecretResolver) { diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 1f6967b6..89e4b86a 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -497,6 +497,111 @@ func (f *fakeStore) UpsertIntegration(context.Context, Integration) (*Integratio return nil, nil } +func TestListStudiosReturnsBundleWithLogos(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + companies: map[int]*tmdb.Company{ + 420: {ID: 420, Name: "Marvel Studios", LogoPath: "/hUze.png"}, + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + studios, err := service.ListStudios(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListStudios: %v", err) + } + if len(studios) != len(BundledStudios) { + t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios)) + } + + var marvel *DiscoverBrandCard + for i := range studios { + if studios[i].Slug == "marvel-studios" { + marvel = &studios[i] + break + } + } + if marvel == nil { + t.Fatal("marvel-studios missing from response") + } + if marvel.LogoURL == nil || *marvel.LogoURL == "" { + t.Errorf("expected non-nil logo URL for marvel, got %v", marvel.LogoURL) + } +} + +func TestListStudiosToleratesLogoLookupFailure(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + companies: map[int]*tmdb.Company{}, + companyErr: map[int]error{ + 420: errors.New("tmdb down"), + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + studios, err := service.ListStudios(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListStudios should not fail wholesale: %v", err) + } + if len(studios) != len(BundledStudios) { + t.Fatalf("len = %d, want %d", len(studios), len(BundledStudios)) + } + for _, s := range studios { + if s.Slug == "marvel-studios" { + if s.LogoURL != nil { + t.Errorf("expected nil logo URL on failure, got %v", *s.LogoURL) + } + return + } + } + t.Error("marvel-studios missing") +} + +func TestListNetworksReturnsBundle(t *testing.T) { + tmdbClient := &fakeTMDBClient{ + networks: map[int]*tmdb.Network{ + 213: {ID: 213, Name: "Netflix", LogoPath: "/wuU9.png"}, + }, + } + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + networks, err := service.ListNetworks(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListNetworks: %v", err) + } + if len(networks) != len(BundledNetworks) { + t.Fatalf("len = %d, want %d", len(networks), len(BundledNetworks)) + } +} + +func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + + genres, err := service.ListGenres(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("ListGenres: %v", err) + } + if len(genres) != len(BundledGenres) { + t.Fatalf("len = %d, want %d", len(genres), len(BundledGenres)) + } + for _, g := range genres { + switch g.Slug { + case "action", "comedy", "drama", "sci-fi", "animation", "documentary": + if !g.SeriesSupported { + t.Errorf("%s should support series", g.Slug) + } + case "horror", "romance": + if g.SeriesSupported { + t.Errorf("%s should not support series", g.Slug) + } + } + if g.GradientFrom == "" || g.GradientTo == "" { + t.Errorf("%s missing gradient", g.Slug) + } + if g.LogoURL != nil { + t.Errorf("%s should not have a logo URL", g.Slug) + } + } +} + type fakePresence struct { available map[MediaType]map[int]bool } @@ -515,9 +620,15 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] } type fakeTMDBClient struct { - page *tmdb.MediaPage - externalIDs *tmdb.ExternalIDs - detail *tmdb.MediaDetail + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs + detail *tmdb.MediaDetail + companies map[int]*tmdb.Company + companyErr map[int]error + networks map[int]*tmdb.Network + networkErr map[int]error + discoverPage *tmdb.MediaPage + discoverErr error } func (f *fakeTMDBClient) SearchMedia(context.Context, string, string, int) (*tmdb.MediaPage, error) { @@ -528,6 +639,16 @@ func (f *fakeTMDBClient) DiscoverSection(context.Context, string, int) (*tmdb.Me return f.page, nil } +func (f *fakeTMDBClient) DiscoverPage(context.Context, string, tmdb.DiscoverParams, int) (*tmdb.MediaPage, error) { + if f.discoverErr != nil { + return nil, f.discoverErr + } + if f.discoverPage != nil { + return f.discoverPage, nil + } + return &tmdb.MediaPage{Results: []tmdb.MediaResult{}}, nil +} + func (f *fakeTMDBClient) GetExternalIDs(context.Context, string, int) (*tmdb.ExternalIDs, error) { return f.externalIDs, nil } @@ -536,6 +657,26 @@ func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.Med return f.detail, nil } +func (f *fakeTMDBClient) GetCompany(_ context.Context, id int) (*tmdb.Company, error) { + if err, ok := f.companyErr[id]; ok { + return nil, err + } + if c, ok := f.companies[id]; ok { + return c, nil + } + return &tmdb.Company{ID: id}, nil +} + +func (f *fakeTMDBClient) GetNetwork(_ context.Context, id int) (*tmdb.Network, error) { + if err, ok := f.networkErr[id]; ok { + return nil, err + } + if n, ok := f.networks[id]; ok { + return n, nil + } + return &tmdb.Network{ID: id}, nil +} + type fakeMovieAdapter struct { result FulfillmentResult status FulfillmentStatus