feat(requests): add ListStudios/ListNetworks/ListGenres with logo cache
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user