Files
silo-server/internal/requests/discover_brand.go
T
Silo Server Migration c3cf485cd9 chore: add planning docs and requests updates
- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example
2026-05-25 12:07:50 -04:00

287 lines
8.7 KiB
Go

package requests
import (
"context"
"fmt"
"strings"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
)
// DiscoverBrandCard is one card on the Studios / Networks / Genres carousels.
// Studios and networks carry a TMDB ID and a logo URL rendered with TMDB's
// duotone filter; genres carry gradient hints and a display name instead.
type DiscoverBrandCard struct {
TMDBID int `json:"tmdb_id,omitempty"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
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 their curated logo URLs.
func (s *Service) ListStudios(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
out := make([]DiscoverBrandCard, 0, len(BundledStudios))
for _, studio := range BundledStudios {
out = append(out, DiscoverBrandCard{
TMDBID: studio.TMDBID,
Slug: studio.Slug,
DisplayName: studio.DisplayName,
LogoURL: duotoneLogoURL(studio.LogoPath),
})
}
return out, nil
}
// ListNetworks returns the bundled TV networks with their curated logo URLs.
func (s *Service) ListNetworks(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
out := make([]DiscoverBrandCard, 0, len(BundledNetworks))
for _, network := range BundledNetworks {
out = append(out, DiscoverBrandCard{
TMDBID: network.TMDBID,
Slug: network.Slug,
DisplayName: network.DisplayName,
LogoURL: duotoneLogoURL(network.LogoPath),
})
}
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(ctx context.Context, _ Viewer) ([]DiscoverBrandCard, error) {
if s == nil || s.store == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
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
}
// duotoneLogoURL returns a TMDB CDN URL that recolors the logo into a
// white-on-light-gray duotone. Studio/network logos vary wildly in color
// and contrast; the duotone treatment keeps every card legible against a
// neutral background. Returns nil for empty paths.
func duotoneLogoURL(path string) *string {
if path == "" {
return nil
}
url := "https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)" + path
return &url
}
// 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"`
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.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
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,
LogoURL: duotoneLogoURL(studio.LogoPath),
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.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
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,
LogoURL: duotoneLogoURL(network.LogoPath),
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.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
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
}