diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index dbc0d814..e605a98f 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "log/slog" + "strings" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" ) // DiscoverBrandCard is one card on the Studios / Networks / Genres carousels. @@ -101,3 +104,186 @@ func (s *Service) resolveLogoURL(ctx context.Context, cache *logoCache, id int, func tmdbImageURL(path, size string) string { return "https://image.tmdb.org/t/p/" + size + path } + +// DiscoverBrowseResponse is the shape returned by the browse endpoints. +// Results share the same MediaResult shape as search and the existing +// discovery sections, so the frontend can reuse RequestPosterCard. +type DiscoverBrowseResponse struct { + Kind string `json:"kind"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + BrandColor string `json:"brand_color,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + MediaType MediaType `json:"media_type"` + Sort string `json:"sort"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` + Results []MediaResult `json:"results"` +} + +var validBrowseSorts = map[string]string{ + "popularity": "popularity.desc", + "vote_average": "vote_average.desc", + "release_date": "primary_release_date.desc", +} + +const defaultBrowseSort = "popularity" + +// BrowseStudio returns a page of movies from a bundled studio, enriched with +// Silo availability and request state. +func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + studio, ok := FindStudioBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "movie") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithCompanies: []int{studio.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "studio", + Slug: studio.Slug, + DisplayName: studio.DisplayName, + BrandColor: studio.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.companyLogos, studio.TMDBID, "company", studio.Slug), + MediaType: MediaTypeMovie, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseNetwork returns a page of series from a bundled TV network. +func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + network, ok := FindNetworkBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + tmdbSort, sortKey, err := normalizeBrowseSort(sort, "tv") + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithNetworks: []int{network.TMDBID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "network", + Slug: network.Slug, + DisplayName: network.DisplayName, + BrandColor: network.BrandColor, + LogoURL: s.resolveLogoURL(ctx, s.networkLogos, network.TMDBID, "network", network.Slug), + MediaType: MediaTypeSeries, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +// BrowseGenre returns a page of movies or series from a bundled genre. +func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, rawMediaType MediaType, sort string, page int) (*DiscoverBrowseResponse, error) { + if s == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + genre, ok := FindGenreBySlug(strings.TrimSpace(slug)) + if !ok { + return nil, ErrNotFound + } + mediaType, err := normalizeMediaType(rawMediaType) + if err != nil { + return nil, fmt.Errorf("%w: media_type is required for genre browse", ErrInvalidInput) + } + + var ( + tmdbMediaType string + genreID int + ) + switch mediaType { + case MediaTypeMovie: + tmdbMediaType = "movie" + genreID = genre.MovieID + case MediaTypeSeries: + tmdbMediaType = "tv" + genreID = genre.SeriesID + } + if genreID == 0 { + return nil, fmt.Errorf("%w: %s has no %s equivalent", ErrInvalidInput, slug, mediaType) + } + + tmdbSort, sortKey, err := normalizeBrowseSort(sort, tmdbMediaType) + if err != nil { + return nil, err + } + tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{ + SortBy: tmdbSort, + WithGenres: []int{genreID}, + VoteCountGte: voteCountFloorForSort(sortKey), + }, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPage(ctx, viewer, tmdbPage) + if err != nil { + return nil, err + } + return &DiscoverBrowseResponse{ + Kind: "genre", + Slug: genre.Slug, + DisplayName: genre.DisplayName, + MediaType: mediaType, + Sort: sortKey, + Page: enriched.Page, + TotalPages: enriched.TotalPages, + Results: enriched.Results, + }, nil +} + +func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) { + sort = strings.TrimSpace(sort) + if sort == "" { + sort = defaultBrowseSort + } + tmdbSort, ok := validBrowseSorts[sort] + if !ok { + return "", "", fmt.Errorf("%w: unknown sort %q", ErrInvalidInput, sort) + } + if sort == "release_date" && tmdbMediaType == "tv" { + tmdbSort = "first_air_date.desc" + } + return tmdbSort, sort, nil +} + +func voteCountFloorForSort(sortKey string) int { + if sortKey == "vote_average" { + return 100 + } + return 0 +} diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 89e4b86a..eb283d2c 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -602,6 +602,113 @@ func TestListGenresReturnsBundleWithSeriesSupportFlag(t *testing.T) { } } +func TestBrowseStudioReturnsEnrichedMovies(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, + TotalPages: 2, + TotalResults: 20, + Results: []tmdb.MediaResult{ + {ID: 24428, MediaType: "movie", Title: "The Avengers", Year: 2012, Popularity: 100.5}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "popularity", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Kind != "studio" || resp.Slug != "marvel-studios" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } + if resp.Page != 1 || resp.TotalPages != 2 { + t.Errorf("pagination = %d/%d", resp.Page, resp.TotalPages) + } + if len(resp.Results) != 1 || resp.Results[0].TMDBID != 24428 { + t.Errorf("results = %+v", resp.Results) + } + if resp.Results[0].Availability == "" { + t.Error("availability should be enriched") + } +} + +func TestBrowseStudioUnknownSlugReturnsNotFound(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "not-a-studio", "popularity", 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestBrowseStudioRejectsBadSort(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "made-up-sort", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseStudioDefaultsBlankSortToPopularity(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{Results: []tmdb.MediaResult{}}} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseStudio(context.Background(), testViewer(1), "marvel-studios", "", 1) + if err != nil { + t.Fatalf("BrowseStudio: %v", err) + } + if resp.Sort != "popularity" { + t.Errorf("sort = %q, want popularity (default)", resp.Sort) + } +} + +func TestBrowseNetworkReturnsSeries(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Page: 1, TotalPages: 1, TotalResults: 1, + Results: []tmdb.MediaResult{ + {ID: 1399, MediaType: "series", Title: "Game of Thrones", Year: 2011}, + }, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "popularity", 1) + if err != nil { + t.Fatalf("BrowseNetwork: %v", err) + } + if resp.MediaType != MediaTypeSeries { + t.Errorf("media_type = %q, want series", resp.MediaType) + } +} + +func TestBrowseGenreRequiresMediaType(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestBrowseGenreSeriesRejectedWhenUnsupported(t *testing.T) { + service := newTestServiceWithTMDB(newFakeStore(), &fakeTMDBClient{}) + _, err := service.BrowseGenre(context.Background(), testViewer(1), "horror", "series", "popularity", 1) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput for horror+series", err) + } +} + +func TestBrowseGenreMovieReturnsResults(t *testing.T) { + tmdbClient := &fakeTMDBClient{discoverPage: &tmdb.MediaPage{ + Results: []tmdb.MediaResult{{ID: 1, MediaType: "movie", Title: "Movie"}}, + }} + service := newTestServiceWithTMDB(newFakeStore(), tmdbClient) + + resp, err := service.BrowseGenre(context.Background(), testViewer(1), "action", "movie", "popularity", 1) + if err != nil { + t.Fatalf("BrowseGenre: %v", err) + } + if resp.Kind != "genre" || resp.Slug != "action" || resp.MediaType != MediaTypeMovie { + t.Errorf("resp = %+v", resp) + } +} + type fakePresence struct { available map[MediaType]map[int]bool }