feat(requests): add BrowseStudio/BrowseNetwork/BrowseGenre service methods
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user