feat(requests): add ListStudios/ListNetworks/ListGenres with logo cache

This commit is contained in:
Silo Server Migration
2026-05-24 18:37:46 -04:00
parent 7f246aae1a
commit 562f97575e
3 changed files with 268 additions and 4 deletions
+103
View File
@@ -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
}
+21 -1
View File
@@ -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) {
+144 -3
View File
@@ -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