diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 8c4da0a6..bc631fe5 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -3256,17 +3256,31 @@ type scopeEntitlementResolver struct { } func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) { - scope, err := r.resolver.Resolve(ctx, access.ResolveInput{ - UserID: userID, - ProfileID: profileID, - SkipPINVerification: true, - }) + scope, err := r.resolveScope(ctx, userID, profileID) if err != nil { return "", err } return scope.MaxPlaybackQuality, nil } +// MaxContentRating implements mediarequests.ContentRatingResolver so request +// discovery honors the profile's parental rating ceiling. +func (r scopeEntitlementResolver) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) { + scope, err := r.resolveScope(ctx, userID, profileID) + if err != nil { + return "", err + } + return scope.MaxContentRating, nil +} + +func (r scopeEntitlementResolver) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) { + return r.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) +} + // audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which // exposes Get) to the audiobooks.SettingsReader interface (which // requires GetString). The two signatures are identical modulo name. diff --git a/internal/api/router.go b/internal/api/router.go index cfedd837..f7e18610 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3357,17 +3357,31 @@ type scopeEntitlementResolver struct { } func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) { - scope, err := r.resolver.Resolve(ctx, access.ResolveInput{ - UserID: userID, - ProfileID: profileID, - SkipPINVerification: true, - }) + scope, err := r.resolveScope(ctx, userID, profileID) if err != nil { return "", err } return scope.MaxPlaybackQuality, nil } +// MaxContentRating implements mediarequests.ContentRatingResolver so request +// discovery honors the profile's parental rating ceiling. +func (r scopeEntitlementResolver) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) { + scope, err := r.resolveScope(ctx, userID, profileID) + if err != nil { + return "", err + } + return scope.MaxContentRating, nil +} + +func (r scopeEntitlementResolver) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) { + return r.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) +} + // metadataAIConfigFromServer derives the metadata translation service config // from the server config. Used at construction and on every config reload. func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config { diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index ec15e2ab..99a7e463 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -24,6 +24,14 @@ const ( maxResponseBody = 1 << 20 // 1 MB maxCollectionPresetResults = 500 defaultResponseCacheTTL = 2 * time.Hour + // certificationCacheTTL is deliberately much longer than the shared + // response TTL: a certification is assigned at release and effectively + // never changes. The stale-window failure mode is safe — a title that + // gains a cert stays hidden (fail-closed) for at most the TTL. + certificationCacheTTL = 7 * 24 * time.Hour + // certificationFetchTimeout bounds the shared (caller-detached) + // singleflight fetch; see GetCertification. + certificationFetchTimeout = 30 * time.Second ) // Client is an HTTP client for the TMDB collection preset API surface. @@ -35,6 +43,7 @@ type Client struct { discoverSectionCache *cache.TTLCache[*MediaPage] discoverPageCache *cache.TTLCache[*MediaPage] externalIDCache *cache.TTLCache[*ExternalIDs] + certificationCache *cache.TTLCache[string] cacheGroup singleflight.Group responseCacheTTL time.Duration } @@ -55,6 +64,7 @@ func NewClient(apiKey string, rateLimit int) *Client { discoverSectionCache: cache.NewTTLCache[*MediaPage](), discoverPageCache: cache.NewTTLCache[*MediaPage](), externalIDCache: cache.NewTTLCache[*ExternalIDs](), + certificationCache: cache.NewTTLCache[string](), responseCacheTTL: defaultResponseCacheTTL, } } @@ -78,6 +88,9 @@ func (c *Client) Close() { if c.externalIDCache != nil { c.externalIDCache.Close() } + if c.certificationCache != nil { + c.certificationCache.Close() + } } // doGet executes a GET request against the TMDB API with rate limiting, @@ -1142,6 +1155,145 @@ func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs { return &cloned } +// GetCertification returns the US content rating for a TMDB title ("PG-13", +// "TV-MA", ...), or "" when the title has no US certification. It uses the +// dedicated release_dates / content_ratings sub-resources instead of the full +// detail payload for the same reason GetExternalIDs does: the detail response +// is 100+ KB and uncached, while these are a country list of a few KB. +// +// Unlike GetMediaDetail's display rating, this deliberately does NOT fall +// back to another country's certification: the value feeds the US-scale +// parental-control ladder, where a foreign "PG" (Canada, Australia, ...) is +// not evidence of US-PG content. No US entry means unresolved, which the +// ladder treats as fail-closed. mediaType accepts Silo-facing +// "movie"/"series" plus TMDB-facing "tv". +func (c *Client) GetCertification(ctx context.Context, mediaType string, id int) (string, error) { + var path string + switch mediaType { + case "movie": + path = fmt.Sprintf("/movie/%d/release_dates", id) + case "series", "tv": + path = fmt.Sprintf("/tv/%d/content_ratings", id) + default: + return "", fmt.Errorf("tmdb: invalid media type: %q", mediaType) + } + + cacheKey := "certification:" + path + if c.certificationCache != nil { + if cached, ok := c.certificationCache.Get(cacheKey); ok { + return cached, nil + } + } + + // DoChan + select rather than Do: the shared fetch must survive any one + // caller's disconnect (it runs detached with its own bound), while each + // caller must still be able to stop waiting on its own cancellation + // instead of being pinned for up to the fetch timeout. + resultCh := c.cacheGroup.DoChan(cacheKey, func() (any, error) { + if c.certificationCache != nil { + if cached, ok := c.certificationCache.Get(cacheKey); ok { + return cached, nil + } + } + fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), certificationFetchTimeout) + defer cancel() + cert, err := c.fetchCertification(fetchCtx, mediaType, path) + if err != nil { + return nil, err + } + // "" (no certification on TMDB) is cached too: it is by far the most + // common case and refetching it on every page load would defeat the + // cache exactly where fail-closed filtering needs it most. + if c.certificationCache != nil { + c.certificationCache.Set(cacheKey, cert, certificationCacheTTL) + } + return cert, nil + }) + select { + case <-ctx.Done(): + return "", ctx.Err() + case result := <-resultCh: + if result.Err != nil { + return "", result.Err + } + cert, ok := result.Val.(string) + if !ok { + return "", fmt.Errorf("tmdb: invalid cached certification response") + } + return cert, nil + } +} + +func (c *Client) fetchCertification(ctx context.Context, mediaType, path string) (string, error) { + if mediaType == "movie" { + var resp releaseDatesResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return "", err + } + return pickUSMovieCertification(&resp), nil + } + var resp contentRatingsResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return "", err + } + return pickUSTVRating(&resp), nil +} + +// usCertificationRank orders US movie certifications for strictest-wins +// resolution across multiple release entries. Mirrors TMDB's own +// /certification/movie/list ordering. Unknown strings (including "NR") rank +// -1 and lose to any recognized rating; a title with ONLY unknown/NR entries +// resolves to that string and fails closed downstream. +var usCertificationRank = map[string]int{ + "G": 1, "PG": 2, "PG-13": 3, "R": 4, "NC-17": 5, +} + +// pickUSMovieCertification is the enforcement-path variant of +// pickMovieCertification: US entries only, no foreign fallback. A title can +// carry several US entries whose certifications disagree — festival "NR" next +// to a theatrical "PG-13", or re-releases rated "PG" and "R" — and TMDB's +// entry order is not meaningful, so the STRICTEST recognized rating wins: +// enforcement must not admit a title on its most lenient certificate. +func pickUSMovieCertification(rd *releaseDatesResponse) string { + if rd == nil { + return "" + } + var picked string + pickedRank := -1 + for _, country := range rd.Results { + if !strings.EqualFold(country.ISO3166, "US") { + continue + } + for _, entry := range country.ReleaseDates { + cert := strings.TrimSpace(entry.Certification) + if cert == "" { + continue + } + rank := usCertificationRank[strings.ToUpper(cert)] // unknown -> 0 + if rank > pickedRank || picked == "" { + picked = cert + pickedRank = rank + } + } + } + return picked +} + +// pickUSTVRating is the enforcement-path variant of pickTVRating: US only. +func pickUSTVRating(cr *contentRatingsResponse) string { + if cr == nil { + return "" + } + for _, entry := range cr.Results { + if strings.EqualFold(entry.ISO3166, "US") { + if rating := strings.TrimSpace(entry.Rating); rating != "" { + return rating + } + } + } + return "" +} + func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) { var resp ExternalIDs if err := c.doGet(ctx, path, &resp); err != nil { diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index cb444f5f..d61b8b28 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -2,10 +2,12 @@ package tmdb import ( "context" + "errors" "net/http" "net/http/httptest" "sync/atomic" "testing" + "time" ) func TestNewClientUsesProjectAPIKeyWhenEmpty(t *testing.T) { @@ -886,3 +888,253 @@ func TestGetExternalIDs(t *testing.T) { t.Fatalf("TVDBID = %d, want 12345", ids.TVDBID) } } + +func TestGetCertificationUsesSubResourceEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/movie/603/release_dates": + // Type 3 (theatrical) US entry wins over the earlier NR entry. + _, _ = w.Write([]byte(`{"results":[ + {"iso_3166_1":"DE","release_dates":[{"certification":"16","type":3}]}, + {"iso_3166_1":"US","release_dates":[{"certification":"NR","type":2},{"certification":"R","type":3}]} + ]}`)) + case "/tv/1396/content_ratings": + _, _ = w.Write([]byte(`{"results":[ + {"iso_3166_1":"DE","rating":"16"}, + {"iso_3166_1":"US","rating":"TV-MA"} + ]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + movieCert, err := client.GetCertification(context.Background(), "movie", 603) + if err != nil { + t.Fatalf("movie GetCertification returned error: %v", err) + } + if movieCert != "R" { + t.Fatalf("movie certification = %q, want R", movieCert) + } + + // Both Silo-facing "series" and TMDB-facing "tv" resolve TV titles. + for _, mediaType := range []string{"series", "tv"} { + tvCert, err := client.GetCertification(context.Background(), mediaType, 1396) + if err != nil { + t.Fatalf("tv GetCertification(%q) returned error: %v", mediaType, err) + } + if tvCert != "TV-MA" { + t.Fatalf("tv certification (%q) = %q, want TV-MA", mediaType, tvCert) + } + } + + if _, err := client.GetCertification(context.Background(), "bogus", 1); err == nil { + t.Fatal("GetCertification accepted invalid media type") + } +} + +// TestGetCertificationIgnoresForeignFallback pins the enforcement-path +// contract: a title with only foreign certifications resolves to "" (fail +// closed on the US ladder), unlike the display path, which falls back to any +// country. A Canadian "PG" must not read as US PG. +func TestGetCertificationIgnoresForeignFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/movie/1/release_dates": + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"CA","release_dates":[{"certification":"PG","type":3}]}]}`)) + case "/tv/2/content_ratings": + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"AU","rating":"PG"}]}`)) + case "/movie/3/release_dates": + // Festival NR + theatrical PG-13 in the US: the rated entry wins. + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","release_dates":[{"certification":"NR","type":2},{"certification":"PG-13","type":3}]}]}`)) + case "/movie/4/release_dates": + // Two US theatrical entries that disagree (PG re-release + R): + // enforcement must take the STRICTEST, not the first. + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","release_dates":[{"certification":"PG","type":3},{"certification":"R","type":3}]}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + if cert, err := client.GetCertification(context.Background(), "movie", 1); err != nil || cert != "" { + t.Fatalf("foreign-only movie cert = %q, %v; want empty", cert, err) + } + if cert, err := client.GetCertification(context.Background(), "tv", 2); err != nil || cert != "" { + t.Fatalf("foreign-only tv cert = %q, %v; want empty", cert, err) + } + if cert, err := client.GetCertification(context.Background(), "movie", 3); err != nil || cert != "PG-13" { + t.Fatalf("US theatrical cert = %q, %v; want PG-13", cert, err) + } + if cert, err := client.GetCertification(context.Background(), "movie", 4); err != nil || cert != "R" { + t.Fatalf("disagreeing US certs = %q, %v; want strictest (R)", cert, err) + } +} + +// TestGetCertificationCallerStopsWaitingOnCancel pins the DoChan behavior: a +// caller whose context dies mid-flight returns promptly with ctx.Err() while +// the shared detached fetch continues for the surviving waiters. +func TestGetCertificationCallerStopsWaitingOnCancel(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + _, err := client.GetCertification(ctx, "tv", 1396) + errCh <- err + }() + <-started + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled caller error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelled caller still blocked on the shared fetch") + } + + // The detached fetch is still completable for a fresh caller. + close(release) + if cert, err := client.GetCertification(context.Background(), "tv", 1396); err != nil || cert != "TV-PG" { + t.Fatalf("surviving caller cert = %q, %v; want TV-PG", cert, err) + } +} + +// TestGetCertificationSurvivesFirstCallerCancellation pins the singleflight +// context fix: the shared fetch runs detached from the initiating caller, so +// that caller disconnecting must not poison the result for concurrent waiters. +func TestGetCertificationSurvivesFirstCallerCancellation(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + firstCtx, cancelFirst := context.WithCancel(context.Background()) + firstErr := make(chan error, 1) + go func() { + _, err := client.GetCertification(firstCtx, "tv", 1396) + firstErr <- err + }() + <-started + + secondCert := make(chan string, 1) + secondErr := make(chan error, 1) + go func() { + cert, err := client.GetCertification(context.Background(), "tv", 1396) + secondCert <- cert + secondErr <- err + }() + // Let the second caller pile onto the in-flight singleflight key, then + // kill the initiating caller's context before the upstream responds. + time.Sleep(50 * time.Millisecond) + cancelFirst() + time.Sleep(50 * time.Millisecond) + close(release) + + if err := <-secondErr; err != nil { + t.Fatalf("second caller failed after first caller cancelled: %v", err) + } + if cert := <-secondCert; cert != "TV-PG" { + t.Fatalf("second caller cert = %q, want TV-PG", cert) + } + <-firstErr // first caller may or may not error; just reap it +} + +func TestGetCertificationCachesIncludingEmpty(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + // No certification anywhere: the common case, which must be cached + // too or fail-closed filtering refetches it on every page load. + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + for i := 0; i < 3; i++ { + cert, err := client.GetCertification(context.Background(), "movie", 42) + if err != nil { + t.Fatalf("GetCertification returned error: %v", err) + } + if cert != "" { + t.Fatalf("certification = %q, want empty", cert) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (empty result must be cached)", got) + } +} + +func TestGetCertificationSingleflightsConcurrentCallers(t *testing.T) { + var calls atomic.Int32 + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`)) + })) + defer server.Close() + + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + const callers = 8 + results := make(chan string, callers) + errs := make(chan error, callers) + for i := 0; i < callers; i++ { + go func() { + cert, err := client.GetCertification(context.Background(), "tv", 1396) + results <- cert + errs <- err + }() + } + // Give the goroutines time to pile onto the singleflight, then release + // the one in-flight upstream request. + time.Sleep(50 * time.Millisecond) + close(release) + + for i := 0; i < callers; i++ { + if err := <-errs; err != nil { + t.Fatalf("GetCertification returned error: %v", err) + } + if cert := <-results; cert != "TV-PG" { + t.Fatalf("certification = %q, want TV-PG", cert) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (singleflight)", got) + } +} diff --git a/internal/requests/discover_brand.go b/internal/requests/discover_brand.go index 17ca01ea..5b0334f4 100644 --- a/internal/requests/discover_brand.go +++ b/internal/requests/discover_brand.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" ) @@ -136,15 +137,19 @@ func (s *Service) BrowseStudio(ctx context.Context, viewer Viewer, slug, sort st if err != nil { return nil, err } - tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", tmdb.DiscoverParams{ + params, ceiling, err := s.browseDiscoverParams(ctx, viewer, "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) + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "movie", params, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPageWithCeiling(ctx, viewer, tmdbPage, ceiling) if err != nil { return nil, err } @@ -177,15 +182,19 @@ func (s *Service) BrowseNetwork(ctx context.Context, viewer Viewer, slug, sort s if err != nil { return nil, err } - tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", tmdb.DiscoverParams{ + params, ceiling, err := s.browseDiscoverParams(ctx, viewer, "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) + tmdbPage, err := s.tmdb.DiscoverPage(ctx, "tv", params, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPageWithCeiling(ctx, viewer, tmdbPage, ceiling) if err != nil { return nil, err } @@ -239,15 +248,19 @@ func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, r if err != nil { return nil, err } - tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, tmdb.DiscoverParams{ + params, ceiling, err := s.browseDiscoverParams(ctx, viewer, 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) + tmdbPage, err := s.tmdb.DiscoverPage(ctx, tmdbMediaType, params, page) + if err != nil { + return nil, err + } + enriched, err := s.enrichPageWithCeiling(ctx, viewer, tmdbPage, ceiling) if err != nil { return nil, err } @@ -263,6 +276,66 @@ func (s *Service) BrowseGenre(ctx context.Context, viewer Viewer, slug string, r }, nil } +// certificationCeilingFor maps a Silo rating ceiling (which spans both the +// movie and TV ladders — see access.RatingRank) onto the US certification +// string TMDB's certification.lte understands for the given media type. +// Returns "" for an empty or unrecognized ceiling, in which case the caller +// omits the parameter. +// +// This push-down is a cost optimization only: TMDB ranks "NR" below "G" and +// matches a title when any one of its US cert entries qualifies, so +// over-ceiling titles still come back (verified ~5% at a G ceiling). The +// authoritative filter is enrichPage's post-hoc certification check. +// +// Because that post-filter cannot resurrect titles TMDB already omitted, the +// mapping must be a SUPERSET of what access.RatingAllowed permits at the +// ceiling, never a subset. Two spots encode that: rank 3 maps to TMDB's +// maximum on each ladder ("NC-17"/"TV-MA" — an R ceiling locally allows +// NC-17, since both are rank 3), and TV rank 0 maps to "TV-G" (TMDB order 3) +// rather than "TV-Y" so TV-Y/TV-Y7 titles are not excluded upstream of our +// own ladder, which ranks them together. +func certificationCeilingFor(ceiling, tmdbMediaType string) string { + rank, ok := access.RatingRank(ceiling) + if !ok { + return "" + } + if tmdbMediaType == "tv" { + switch rank { + case 0: + return "TV-G" + case 1: + return "TV-PG" + case 2: + return "TV-14" + default: + return "TV-MA" + } + } + switch rank { + case 0: + return "G" + case 1: + return "PG" + case 2: + return "PG-13" + default: + return "NC-17" + } +} + +// browseDiscoverParams applies the viewer's rating ceiling as a TMDB-side +// certification.lte pre-filter on top of the base params. It returns the +// resolved ceiling so the caller can reuse it for post-filter enrichment +// without a second scope resolution. +func (s *Service) browseDiscoverParams(ctx context.Context, viewer Viewer, tmdbMediaType string, params tmdb.DiscoverParams) (tmdb.DiscoverParams, string, error) { + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return tmdb.DiscoverParams{}, "", err + } + params.CertificationLte = certificationCeilingFor(ceiling, tmdbMediaType) + return params, ceiling, nil +} + func normalizeBrowseSort(sort, tmdbMediaType string) (string, string, error) { sort = strings.TrimSpace(sort) if sort == "" { diff --git a/internal/requests/entitlements.go b/internal/requests/entitlements.go index bcd7734c..cdb22d49 100644 --- a/internal/requests/entitlements.go +++ b/internal/requests/entitlements.go @@ -18,13 +18,27 @@ func NewAccessEntitlements(resolver *access.Resolver) EntitlementResolver { } func (e accessEntitlements) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) { - scope, err := e.resolver.Resolve(ctx, access.ResolveInput{ - UserID: userID, - ProfileID: profileID, - SkipPINVerification: true, - }) + scope, err := e.resolveScope(ctx, userID, profileID) if err != nil { return "", err } return scope.MaxPlaybackQuality, nil } + +// MaxContentRating implements ContentRatingResolver so request discovery +// honors the profile's parental rating ceiling. +func (e accessEntitlements) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) { + scope, err := e.resolveScope(ctx, userID, profileID) + if err != nil { + return "", err + } + return scope.MaxContentRating, nil +} + +func (e accessEntitlements) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) { + return e.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: profileID, + SkipPINVerification: true, + }) +} diff --git a/internal/requests/rating_filter_test.go b/internal/requests/rating_filter_test.go new file mode 100644 index 00000000..8f6cd3d6 --- /dev/null +++ b/internal/requests/rating_filter_test.go @@ -0,0 +1,559 @@ +package requests + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +func newRatedService(store *fakeStore, client *certTMDBClient, presence *fakePresence, ceiling string) *Service { + if presence == nil { + presence = &fakePresence{} + } + service := NewService(store, client, presence) + service.SetEntitlementResolver(ratedCeiling{rating: ceiling}) + return service +} + +func discoverTestPage() *tmdb.MediaPage { + return &tmdb.MediaPage{ + Page: 1, + TotalPages: 10, + TotalResults: 200, + Results: []tmdb.MediaResult{ + {ID: 1, MediaType: "movie", Title: "Family Movie"}, + {ID: 2, MediaType: "movie", Title: "Adult Movie"}, + {ID: 3, MediaType: "movie", Title: "Unrated Movie"}, + {ID: 4, MediaType: "movie", Title: "Foreign Cert Movie"}, + }, + } +} + +func TestDiscoverFiltersResultsAboveCeiling(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{page: discoverTestPage()}, + certs: map[int]string{ + 1: "G", + 2: "R", + 3: "", // no certification on TMDB + 4: "FSK 16", // foreign-only certification, unknown to the ladder + }, + } + presence := &fakePresence{} + service := newRatedService(store, client, presence, "PG") + + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(section.Results) != 1 || section.Results[0].TMDBID != 1 { + t.Fatalf("results = %+v, want only the G-rated title", section.Results) + } + // TMDB's totals pass through untouched (page keeps TMDB cursor semantics). + if section.TotalPages != 10 || section.TotalResults != 200 { + t.Fatalf("totals = %d/%d, want TMDB's 10/200", section.TotalPages, section.TotalResults) + } + // Filtering runs before presence lookup, so hidden titles never reach it. + for _, candidate := range presence.got { + if candidate.TMDBID != 1 { + t.Fatalf("presence saw filtered-out candidate %+v", candidate) + } + } +} + +// TestDiscoverFilterDropsNRTitles pins the regression the TMDB-side +// certification.lte pre-filter cannot catch: TMDB ranks "NR" below "G", and a +// title with several US cert entries matches when any one qualifies. The +// picked certification for such titles is "NR", which the ladder rejects. +func TestDiscoverFilterDropsNRTitles(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{page: &tmdb.MediaPage{ + Page: 1, + Results: []tmdb.MediaResult{ + {ID: 10, MediaType: "movie", Title: "Explicitly NR"}, + {ID: 11, MediaType: "movie", Title: "Clean G"}, + }, + }}, + certs: map[int]string{10: "NR", 11: "G"}, + } + service := newRatedService(store, client, nil, "G") + + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(section.Results) != 1 || section.Results[0].TMDBID != 11 { + t.Fatalf("results = %+v, want NR title dropped", section.Results) + } +} + +func TestDiscoverUnrestrictedViewerSkipsCertificationLookups(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{page: discoverTestPage()}, + } + service := newRatedService(store, client, nil, "") + + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(section.Results) != 4 { + t.Fatalf("results = %d, want all 4 unfiltered", len(section.Results)) + } + if calls := client.certCalls.Load(); calls != 0 { + t.Fatalf("certification calls = %d, want 0 for an unrestricted viewer", calls) + } +} + +func TestDiscoverCertificationErrorPropagates(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{page: discoverTestPage()}, + certErr: errors.New("tmdb unavailable"), + } + service := newRatedService(store, client, nil, "PG") + + // A failed certification lookup must surface as an error, not as a + // silently empty (fail-closed) page that reads as "no matches". + if _, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1); err == nil { + t.Fatal("Discover succeeded, want certification error to propagate") + } +} + +// pagedCertTMDBClient serves distinct section pages so backfill behavior is +// observable: which TMDB pages were fetched and what survived. +type pagedCertTMDBClient struct { + certTMDBClient + pages map[int]*tmdb.MediaPage + fetchedPage []int +} + +func (f *pagedCertTMDBClient) DiscoverSection(_ context.Context, _ string, page int) (*tmdb.MediaPage, error) { + f.mu.Lock() + f.fetchedPage = append(f.fetchedPage, page) + f.mu.Unlock() + if p, ok := f.pages[page]; ok { + return p, nil + } + return &tmdb.MediaPage{Page: page, TotalPages: len(f.pages), Results: nil}, nil +} + +func TestDiscoverBackfillsSectionFromLaterPages(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + // 3 TMDB pages; one G title per page among R titles. Ceiling PG should + // walk the whole window (pages 1-5, capped at TotalPages=3) and surface + // all three G titles on Silo page 1. + pages := map[int]*tmdb.MediaPage{} + certs := map[int]string{} + for p := 1; p <= 3; p++ { + var results []tmdb.MediaResult + for i := 0; i < 20; i++ { + id := p*100 + i + results = append(results, tmdb.MediaResult{ID: id, MediaType: "movie", Title: "Movie"}) + if i == 0 { + certs[id] = "G" + } else { + certs[id] = "R" + } + } + pages[p] = &tmdb.MediaPage{Page: p, TotalPages: 3, TotalResults: 60, Results: results} + } + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: certs}, + pages: pages, + } + service := NewService(store, client, &fakePresence{}) + service.SetEntitlementResolver(ratedCeiling{rating: "PG"}) + + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + ids := make([]int, 0, len(section.Results)) + for _, r := range section.Results { + ids = append(ids, r.TMDBID) + } + if len(ids) != 3 || ids[0] != 100 || ids[1] != 200 || ids[2] != 300 { + t.Fatalf("result ids = %v, want [100 200 300] (one survivor per TMDB page)", ids) + } + if got := client.fetchedPage; len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 { + t.Fatalf("fetched TMDB pages = %v, want [1 2 3] (stop at TotalPages)", got) + } + if section.TotalPages != 3 { + t.Fatalf("TotalPages = %d, want TMDB's 3", section.TotalPages) + } + // All upstream pages were consumed within budget — nothing left to resume. + if section.NextPage != 0 { + t.Fatalf("NextPage = %d, want 0 (exhausted)", section.NextPage) + } +} + +func TestDiscoverBackfillStopsWhenPageFull(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + // Page 1 survives fully; the window must stop after it. + var results []tmdb.MediaResult + certs := map[int]string{} + for i := 0; i < 20; i++ { + results = append(results, tmdb.MediaResult{ID: 100 + i, MediaType: "movie", Title: "Movie"}) + certs[100+i] = "G" + } + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: certs}, + pages: map[int]*tmdb.MediaPage{ + 1: {Page: 1, TotalPages: 500, TotalResults: 10000, Results: results}, + }, + } + service := NewService(store, client, &fakePresence{}) + service.SetEntitlementResolver(ratedCeiling{rating: "PG"}) + + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if len(section.Results) != 20 { + t.Fatalf("results = %d, want 20", len(section.Results)) + } + if got := client.fetchedPage; len(got) != 1 || got[0] != 1 { + t.Fatalf("fetched TMDB pages = %v, want just [1] (page already full)", got) + } +} + +func TestDiscoverBackfillNextPageResumesWithoutSkippingOrRepeating(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + certs := map[int]string{} + pages := map[int]*tmdb.MediaPage{} + for p := 1; p <= 10; p++ { + id := p * 100 + certs[id] = "G" + pages[p] = &tmdb.MediaPage{Page: p, TotalPages: 10, TotalResults: 200, + Results: []tmdb.MediaResult{{ID: id, MediaType: "movie", Title: "Movie"}}} + } + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: certs}, + pages: pages, + } + service := NewService(store, client, &fakePresence{}) + service.SetEntitlementResolver(ratedCeiling{rating: "PG"}) + + // One survivor per page and a budget of 5: page 1 consumes TMDB 1-5. + first, err := service.Discover(context.Background(), testViewer(1), "trending_movies", 1) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if first.NextPage != 6 { + t.Fatalf("NextPage = %d, want 6", first.NextPage) + } + client.fetchedPage = nil + second, err := service.Discover(context.Background(), testViewer(1), "trending_movies", first.NextPage) + if err != nil { + t.Fatalf("Discover returned error: %v", err) + } + if got := client.fetchedPage; len(got) != 5 || got[0] != 6 || got[4] != 10 { + t.Fatalf("fetched TMDB pages = %v, want [6 7 8 9 10]", got) + } + var ids []int + for _, r := range append(first.Results, second.Results...) { + ids = append(ids, r.TMDBID) + } + if len(ids) != 10 || ids[0] != 100 || ids[9] != 1000 { + t.Fatalf("combined ids = %v, want 100..1000 with no gap or repeat", ids) + } + if second.NextPage != 0 { + t.Fatalf("NextPage after last page = %d, want 0", second.NextPage) + } +} + +// TestDiscoverBackfillPreservesOverflowAcrossPages pins the review regression: +// with a permissive ceiling most of TMDB page 1 survives, and an early "20 +// survived, stop scanning" break must not drop the un-consumed pages — +// NextPage has to resume exactly where consumption stopped. +func TestDiscoverBackfillPreservesOverflowAcrossPages(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + certs := map[int]string{} + pages := map[int]*tmdb.MediaPage{} + for p := 1; p <= 3; p++ { + var results []tmdb.MediaResult + for i := 0; i < 20; i++ { + id := p*100 + i + results = append(results, tmdb.MediaResult{ID: id, MediaType: "movie", Title: "Movie"}) + certs[id] = "R" // permissive ceiling: everything survives + } + pages[p] = &tmdb.MediaPage{Page: p, TotalPages: 3, TotalResults: 60, Results: results} + } + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: certs}, + pages: pages, + } + service := NewService(store, client, &fakePresence{}) + service.SetEntitlementResolver(ratedCeiling{rating: "R"}) + + seen := map[int]bool{} + page := 1 + for hops := 0; page > 0; hops++ { + if hops > 10 { + t.Fatal("pagination did not terminate") + } + section, err := service.Discover(context.Background(), testViewer(1), "trending_movies", page) + if err != nil { + t.Fatalf("Discover(page=%d) returned error: %v", page, err) + } + for _, r := range section.Results { + if seen[r.TMDBID] { + t.Fatalf("title %d repeated across pages", r.TMDBID) + } + seen[r.TMDBID] = true + } + page = section.NextPage + } + if len(seen) != 60 { + t.Fatalf("saw %d unique titles across all pages, want all 60 (overflow lost)", len(seen)) + } +} + +// countingCeiling counts scope resolutions; the production resolver hits the +// user store and policy engine per call, so call count is a real cost. +type countingCeiling struct { + rating string + calls atomic.Int64 +} + +func (f *countingCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { + return "", nil +} + +func (f *countingCeiling) MaxContentRating(context.Context, int, string) (string, error) { + f.calls.Add(1) + return f.rating, nil +} + +func TestDiscoverAllResolvesCeilingOnce(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: map[int]string{}}, + pages: map[int]*tmdb.MediaPage{ + 1: {Page: 1, TotalPages: 1, Results: nil}, + }, + } + service := NewService(store, client, &fakePresence{}) + ceiling := &countingCeiling{rating: "PG"} + service.SetEntitlementResolver(ceiling) + + if _, err := service.DiscoverAll(context.Background(), testViewer(1)); err != nil { + t.Fatalf("DiscoverAll returned error: %v", err) + } + if got := ceiling.calls.Load(); got != 1 { + t.Fatalf("ceiling resolutions = %d, want 1 for the whole DiscoverAll", got) + } +} + +func TestDiscoverAllUsesSmallerBackfillBudget(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + // Nothing survives filtering, so backfill always runs to its budget. + pages := map[int]*tmdb.MediaPage{} + for p := 1; p <= 10; p++ { + pages[p] = &tmdb.MediaPage{Page: p, TotalPages: 10, TotalResults: 200, + Results: []tmdb.MediaResult{{ID: p * 100, MediaType: "movie", Title: "Movie"}}} + } + client := &pagedCertTMDBClient{ + certTMDBClient: certTMDBClient{certs: map[int]string{}}, // all unrated -> dropped + pages: pages, + } + service := NewService(store, client, &fakePresence{}) + service.SetEntitlementResolver(ratedCeiling{rating: "PG"}) + + sections, err := service.DiscoverAll(context.Background(), testViewer(1)) + if err != nil { + t.Fatalf("DiscoverAll returned error: %v", err) + } + // 6 sections x aggregate budget of 2 pages = 12 list fetches, bounding the + // cold-path cost the review flagged (vs 6 x 5 = 30 at the single budget). + want := len(sections) * sectionBackfillBudgetAggregate + if got := len(client.fetchedPage); got != want { + t.Fatalf("total TMDB page fetches = %d, want %d", got, want) + } +} + +func TestGetDetailBlocksTitleAboveCeiling(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{detail: &tmdb.MediaDetail{ + MediaType: "movie", + ID: 42, + Title: "Adult Movie", + ContentRating: "R", + }}, + certs: map[int]string{42: "R"}, + } + service := newRatedService(store, client, nil, "PG") + + if _, err := service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 42); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetDetail error = %v, want ErrNotFound", err) + } + + // Same title is visible without a ceiling. + service.SetEntitlementResolver(ratedCeiling{rating: ""}) + detail, err := service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 42) + if err != nil { + t.Fatalf("GetDetail returned error: %v", err) + } + if detail.TMDBID != 42 { + t.Fatalf("detail id = %d, want 42", detail.TMDBID) + } +} + +// TestGetDetailIgnoresForeignDisplayRating pins the round-2 review finding: +// the guard compares the US-only enforcement certification, not the detail +// payload's display rating. A foreign "PG" (same string as US PG) in the +// display field must not admit a title whose US certification is unresolved. +func TestGetDetailIgnoresForeignDisplayRating(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{detail: &tmdb.MediaDetail{ + MediaType: "movie", + ID: 77, + Title: "Foreign Only", + ContentRating: "PG", // display fallback from a non-US country + }}, + certs: map[int]string{77: ""}, // US-only enforcement lookup: unresolved + } + service := newRatedService(store, client, nil, "PG") + + if _, err := service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 77); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetDetail error = %v, want ErrNotFound (foreign display rating must not pass)", err) + } +} + +func TestGetDetailBlocksUnratedTitleUnderCeiling(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{ + fakeTMDBClient: fakeTMDBClient{detail: &tmdb.MediaDetail{ + MediaType: "movie", + ID: 43, + Title: "Unrated Movie", + }}, + } + service := newRatedService(store, client, nil, "PG") + + if _, err := service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 43); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetDetail error = %v, want ErrNotFound (fail closed on missing rating)", err) + } +} + +func TestCreateRequestBlocksTitleAboveCeiling(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{certs: map[int]string{7: "R"}} + service := newRatedService(store, client, nil, "PG") + + _, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 7, + Title: "Adult Movie", + }) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("CreateRequest error = %v, want ErrForbidden", err) + } + if store.count != 0 { + t.Fatalf("stored requests = %d, want 0", store.count) + } +} + +func TestCreateRequestAllowsTitleWithinCeiling(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{certs: map[int]string{8: "PG"}} + service := newRatedService(store, client, nil, "PG") + + req, err := service.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 8, + Title: "Family Movie", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.TMDBID != 8 { + t.Fatalf("request tmdb id = %d, want 8", req.TMDBID) + } +} + +func TestBrowsePushesCertificationCeilingToTMDB(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = true + client := &certTMDBClient{} + service := newRatedService(store, client, nil, "PG-13") + + if _, err := service.BrowseStudio(context.Background(), testViewer(1), "pixar", "", 1); err != nil { + t.Fatalf("BrowseStudio returned error: %v", err) + } + if got := client.gotDiscoverParams.CertificationLte; got != "PG-13" { + t.Fatalf("movie certification.lte = %q, want PG-13", got) + } + + if _, err := service.BrowseNetwork(context.Background(), testViewer(1), "netflix", "", 1); err != nil { + t.Fatalf("BrowseNetwork returned error: %v", err) + } + if got := client.gotDiscoverParams.CertificationLte; got != "TV-14" { + t.Fatalf("tv certification.lte = %q, want TV-14", got) + } + + service.SetEntitlementResolver(ratedCeiling{rating: ""}) + if _, err := service.BrowseStudio(context.Background(), testViewer(1), "pixar", "", 1); err != nil { + t.Fatalf("BrowseStudio returned error: %v", err) + } + if got := client.gotDiscoverParams.CertificationLte; got != "" { + t.Fatalf("certification.lte = %q, want empty without a ceiling", got) + } +} + +func TestCertificationCeilingFor(t *testing.T) { + cases := []struct { + ceiling string + mediaType string + want string + }{ + {"G", "movie", "G"}, + {"PG", "movie", "PG"}, + {"PG-13", "movie", "PG-13"}, + // Rank 3 maps to the ladder maximum: an R ceiling locally allows + // NC-17 (same rank), and the pre-filter must stay a superset of the + // post-filter or allowed titles vanish upstream unrecoverably. + {"R", "movie", "NC-17"}, + {"NC-17", "movie", "NC-17"}, + {"TV-G", "tv", "TV-G"}, + {"TV-Y7", "tv", "TV-PG"}, + {"TV-14", "tv", "TV-14"}, + {"TV-MA", "tv", "TV-MA"}, + // The ceiling field holds one value spanning both ladders, so a + // movie-scale ceiling must map onto the TV ladder and vice versa. + {"PG-13", "tv", "TV-14"}, + {"TV-14", "movie", "PG-13"}, + {"", "movie", ""}, + {"BOGUS", "movie", ""}, + {"", "tv", ""}, + } + for _, tc := range cases { + if got := certificationCeilingFor(tc.ceiling, tc.mediaType); got != tc.want { + t.Errorf("certificationCeilingFor(%q, %q) = %q, want %q", tc.ceiling, tc.mediaType, got, tc.want) + } + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index 673fd8fc..bd23ec1e 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -25,8 +25,16 @@ type TMDBExternalIDClient interface { GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error) } +// TMDBCertificationClient resolves a title's content rating. Detected by type +// assertion on the service's TMDBClient, like TMDBExternalIDClient. +type TMDBCertificationClient interface { + GetCertification(ctx context.Context, mediaType string, id int) (string, error) +} + const externalIDHydrationConcurrency = 4 +const certificationHydrationConcurrency = 8 + type EntitlementResolver interface { // MaxPlaybackQuality returns the requester's effective playback-quality // ceiling (already combining account- and profile-level caps). Empty string @@ -34,6 +42,15 @@ type EntitlementResolver interface { MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) } +// ContentRatingResolver resolves the viewer's effective parental rating +// ceiling. Detected by type assertion on the service's EntitlementResolver so +// existing EntitlementResolver fakes keep compiling. +type ContentRatingResolver interface { + // MaxContentRating returns the profile's content-rating ceiling. Empty + // string means "no ceiling". + MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) +} + // RequesterIdentityResolver resolves a requesting user id into the identity a // per-user request_router plugin needs (e.g. Seerr attribution by email). type RequesterIdentityResolver interface { @@ -60,6 +77,12 @@ type DiscoverySection struct { TotalPages int `json:"total_pages"` TotalResults int `json:"total_results"` Results []MediaResult `json:"results"` + // NextPage is the cursor to request for the following page, needed when + // rating-filter backfill consumes more than one TMDB page per request + // (page+1 would repeat consumed pages). 0 when there are no more pages. + // Additive v1 field; absent (0) also when the viewer is unrestricted and + // plain page+1 semantics apply. + NextPage int `json:"next_page,omitempty"` } func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service { @@ -106,6 +129,214 @@ func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID st return q } +// viewerContentCeiling resolves the viewer's parental rating ceiling. Empty +// string means unrestricted. Unlike the quality ceiling this is a safety +// filter, so a resolver error propagates instead of degrading: silently +// treating a failed lookup as "unrestricted" would leak adult content to a +// kid profile, and treating it as "restricted" would render every carousel +// empty with no visible cause. +func (s *Service) viewerContentCeiling(ctx context.Context, viewer Viewer) (string, error) { + resolver, ok := s.entitlements.(ContentRatingResolver) + if !ok { + return "", nil + } + return resolver.MaxContentRating(ctx, viewer.UserID, viewer.ProfileID) +} + +type certKey struct { + mediaType MediaType + id int +} + +// hydrateCertifications resolves content ratings for every unique +// (mediaType, id) pair on the page. The TMDB client caches certifications +// title-keyed with a long TTL, so in steady state this issues no requests. +func (s *Service) hydrateCertifications(ctx context.Context, raw *tmdb.MediaPage) (map[certKey]string, error) { + client, ok := s.tmdb.(TMDBCertificationClient) + if !ok { + return nil, fmt.Errorf("requests: tmdb client cannot resolve certifications") + } + + keys := make([]certKey, 0, len(raw.Results)) + seen := map[certKey]bool{} + for _, item := range raw.Results { + mediaType, err := normalizeMediaType(MediaType(item.MediaType)) + if err != nil || item.ID <= 0 { + continue + } + key := certKey{mediaType: mediaType, id: item.ID} + if !seen[key] { + seen[key] = true + keys = append(keys, key) + } + } + + certs := make([]string, len(keys)) + group, gctx := errgroup.WithContext(ctx) + group.SetLimit(certificationHydrationConcurrency) + for i, key := range keys { + i, key := i, key + group.Go(func() error { + cert, err := client.GetCertification(gctx, tmdbMediaType(key.mediaType), key.id) + if err != nil { + return err + } + certs[i] = cert + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + + out := make(map[certKey]string, len(keys)) + for i, key := range keys { + out[key] = certs[i] + } + return out, nil +} + +// Section backfill: fail-closed filtering hides most of a TMDB section page +// for a restricted profile (typically 15+ of 20, since unrated titles are +// dropped), which renders as a nearly empty carousel. To compensate, a +// restricted viewer's section page consumes consecutive TMDB pages, starting +// at the requested page, until a page's worth of titles survives filtering or +// the per-request budget (sectionBackfillMaxPagesPerRequest) is spent. +// +// Pagination stays honest through two properties: `page` keeps plain TMDB +// cursor semantics (same as for unrestricted viewers), and the response's +// next_page reports the cursor after the last TMDB page actually consumed. +// Every survivor from a consumed page is returned — nothing is trimmed and no +// consumed page is partially dropped — so resuming at next_page never skips +// or repeats an allowed title. The budget also bounds the cold-cache cost: a +// permissive ceiling (R/TV-MA) fills from one TMDB page and stops immediately, +// while a strict ceiling spends at most the budget per request. +const ( + // Per-request TMDB page budgets. A single-section request can afford a + // deeper scan than the six-section DiscoverAll aggregate: on a cold + // certification cache each consumed page costs up to 20 cert lookups + // against the client's rate limiter, so DiscoverAll's worst case is + // 6 sections x sectionBackfillBudgetAggregate pages x 20. Keeping the + // aggregate budget small bounds the first-paint latency for a restricted + // profile; the per-section endpoint (carousel "load more") gets the + // deeper budget. Steady state is unaffected — certifications are cached + // for 7 days, shared across all profiles. + sectionBackfillBudgetSingle = 5 + sectionBackfillBudgetAggregate = 2 + sectionResultsPerPage = 20 + sectionBackfillMaxPage = 500 // TMDB hard-caps page at 500 +) + +func (s *Service) backfillSectionPage(ctx context.Context, section string, page, pageBudget int, ceiling string) (result *tmdb.MediaPage, nextPage int, err error) { + if page <= 0 { + page = 1 + } + if pageBudget <= 0 { + pageBudget = 1 + } + + out := &tmdb.MediaPage{Page: page} + var results []tmdb.MediaResult + // Trending/popular orderings shift between fetches, so consecutive TMDB + // pages can overlap; dedupe within the request. + seen := map[certKey]bool{} + totalPages := page // until TMDB tells us the real count, assume the current page exists + consumed := 0 + for next := page; consumed < pageBudget && next <= totalPages && next <= sectionBackfillMaxPage; next++ { + // Enough survived — stop before spending another TMDB page. Titles on + // unconsumed pages are not lost: next_page points at the first page + // this request did not consume. + if len(results) >= sectionResultsPerPage { + break + } + raw, err := s.tmdb.DiscoverSection(ctx, section, next) + if err != nil { + return nil, 0, err + } + if raw == nil { + break + } + consumed++ + nextPage = next + 1 + if raw.TotalPages > 0 { + totalPages = raw.TotalPages + out.TotalResults = raw.TotalResults + } + filtered, err := s.filterPageByCeiling(ctx, raw, ceiling) + if err != nil { + return nil, 0, err + } + for _, item := range filtered.Results { + key := certKey{mediaType: MediaType(item.MediaType), id: item.ID} + if !seen[key] { + seen[key] = true + results = append(results, item) + } + } + } + out.Results = results + // TotalPages/TotalResults stay TMDB's unfiltered counts — page keeps TMDB + // cursor semantics, and the filtered totals are unknowable without a full + // scan. next_page is the honest resume cursor. + out.TotalPages = totalPages + if nextPage > totalPages || nextPage > sectionBackfillMaxPage { + nextPage = 0 // exhausted + } + return out, nextPage, nil +} + +// ensureCreateAllowedByCeiling rejects request submissions for titles above +// the viewer's rating ceiling. List filtering alone is cosmetic — the create +// endpoint is directly callable with a guessable TMDB id. +func (s *Service) ensureCreateAllowedByCeiling(ctx context.Context, viewer Viewer, input CreateRequestInput) error { + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return err + } + if ceiling == "" { + return nil + } + client, ok := s.tmdb.(TMDBCertificationClient) + if !ok { + return fmt.Errorf("requests: tmdb client cannot resolve certifications") + } + cert, err := client.GetCertification(ctx, tmdbMediaType(input.MediaType), input.TMDBID) + if err != nil { + return err + } + if !access.RatingAllowed(cert, ceiling) { + return ErrForbidden + } + return nil +} + +// filterPageByCeiling drops results whose certification exceeds the ceiling, +// failing closed on missing or unrecognized certifications. TMDB's own +// certification.lte pre-filter (applied on browse paths) is not trusted for +// this: it ranks "NR" below "G" and matches titles when any one of several +// US cert entries qualifies, both of which leak over-ceiling titles. +// TotalPages/TotalResults are intentionally left as TMDB reported them — +// recomputing them would require scanning every page, and short pages are +// benign for the carousel/browse UIs. +func (s *Service) filterPageByCeiling(ctx context.Context, raw *tmdb.MediaPage, ceiling string) (*tmdb.MediaPage, error) { + certs, err := s.hydrateCertifications(ctx, raw) + if err != nil { + return nil, err + } + filtered := *raw + filtered.Results = make([]tmdb.MediaResult, 0, len(raw.Results)) + for _, item := range raw.Results { + mediaType, err := normalizeMediaType(MediaType(item.MediaType)) + if err != nil || item.ID <= 0 { + continue + } + if access.RatingAllowed(certs[certKey{mediaType: mediaType, id: item.ID}], ceiling) { + filtered.Results = append(filtered.Results, item) + } + } + return &filtered, nil +} + // allowedQualities returns the qualities a request may receive: 1080p always, // plus 2160p when force-dual is on or the requester's entitlement ceiling allows 4K. func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) []Quality { @@ -217,6 +448,20 @@ func (s *Service) Search(ctx context.Context, viewer Viewer, query string, media } func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, page int) (*DiscoverySection, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return nil, err + } + return s.discover(ctx, viewer, section, page, sectionBackfillBudgetSingle, ceiling) +} + +// discover renders one section for an already-resolved ceiling. Callers own +// the ceiling lookup so a DiscoverAll fan-out resolves the viewer scope once, +// not once per section per page. +func (s *Service) discover(ctx context.Context, viewer Viewer, section string, page, backfillBudget int, ceiling string) (*DiscoverySection, error) { if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } @@ -227,11 +472,18 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p if _, ok := discoverySectionTitles[section]; !ok { return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput) } - raw, err := s.tmdb.DiscoverSection(ctx, section, page) + var raw *tmdb.MediaPage + var err error + var nextPage int + if ceiling != "" { + raw, nextPage, err = s.backfillSectionPage(ctx, section, page, backfillBudget, ceiling) + } else { + raw, err = s.tmdb.DiscoverSection(ctx, section, page) + } if err != nil { return nil, err } - enriched, err := s.enrichPage(ctx, viewer, raw) + enriched, err := s.enrichPageWithCeiling(ctx, viewer, raw, ceiling) if err != nil { return nil, err } @@ -242,6 +494,7 @@ func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, p TotalPages: enriched.TotalPages, TotalResults: enriched.TotalResults, Results: enriched.Results, + NextPage: nextPage, }, nil } @@ -252,13 +505,17 @@ func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySe if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return nil, err + } sections := make([]DiscoverySection, len(discoverySectionOrder)) group, gctx := errgroup.WithContext(ctx) group.SetLimit(externalIDHydrationConcurrency) for i, key := range discoverySectionOrder { i, key := i, key group.Go(func() error { - section, err := s.Discover(gctx, viewer, key, 1) + section, err := s.discover(gctx, viewer, key, 1, sectionBackfillBudgetAggregate, ceiling) if err != nil { return err } @@ -298,6 +555,30 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT return nil, ErrNotFound } + // Deep-linking a detail page must not bypass the discovery rating filter. + // The guard uses the US-only enforcement certification (GetCertification, + // cached), NOT raw.ContentRating: the display rating falls back to any + // country's cert, and a foreign "PG"/"G" is the same string as the US + // rating, so it would pass the US ladder. ErrNotFound rather than + // ErrForbidden: a restricted profile shouldn't learn the title exists. + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return nil, err + } + if ceiling != "" { + client, ok := s.tmdb.(TMDBCertificationClient) + if !ok { + return nil, fmt.Errorf("requests: tmdb client cannot resolve certifications") + } + cert, err := client.GetCertification(ctx, tmdbMediaType(mediaType), tmdbID) + if err != nil { + return nil, err + } + if !access.RatingAllowed(cert, ceiling) { + return nil, ErrNotFound + } + } + policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err @@ -362,7 +643,7 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT if len(raw.Recommendations) > 0 { recPage := &tmdb.MediaPage{Results: raw.Recommendations} - enriched, err := s.enrichPage(ctx, viewer, recPage) + enriched, err := s.enrichPageWithCeiling(ctx, viewer, recPage, ceiling) if err != nil { return nil, err } @@ -389,6 +670,9 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create if err != nil { return nil, err } + if err := s.ensureCreateAllowedByCeiling(ctx, viewer, normalized); err != nil { + return nil, err + } s.enrichExternalIDs(ctx, &normalized) isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID) @@ -759,7 +1043,15 @@ func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus if err != nil { return FeatureStatus{}, err } - return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil + // Rating enforcement is active when the wiring can resolve both a + // profile ceiling and per-title certifications; with either missing the + // server behaves like an older version, and clients should know that. + _, hasRatings := s.entitlements.(ContentRatingResolver) + _, hasCerts := s.tmdb.(TMDBCertificationClient) + return FeatureStatus{ + RequestsEnabled: settings.RequestsEnabled, + RatingRestrictionsEnforced: hasRatings && hasCerts, + }, nil } func (s *Service) ensureRequestsEnabled(ctx context.Context) error { @@ -1075,9 +1367,31 @@ func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePol } func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage) (*MediaPage, error) { + ceiling, err := s.viewerContentCeiling(ctx, viewer) + if err != nil { + return nil, err + } + return s.enrichPageWithCeiling(ctx, viewer, raw, ceiling) +} + +// enrichPageWithCeiling is enrichPage for callers that already resolved the +// viewer's rating ceiling (discovery, browse, detail). The production +// resolver loads the user, profile, and policy on every call, so resolving +// once per request instead of again per page matters — DiscoverAll otherwise +// doubles to 12 resolutions per load. +func (s *Service) enrichPageWithCeiling(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage, ceiling string) (*MediaPage, error) { if raw == nil { return &MediaPage{Results: []MediaResult{}}, nil } + var err error + if ceiling != "" { + // Filtering before the presence/active-request lookups below means + // those (and their external-ID hydration) only pay for surviving items. + raw, err = s.filterPageByCeiling(ctx, raw, ceiling) + if err != nil { + return nil, err + } + } policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index c3952897..faca56f7 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "testing" "time" @@ -2058,15 +2059,16 @@ func (f *fakePresence) LookupTMDB(_ context.Context, mediaType MediaType, ids [] } type fakeTMDBClient struct { - mu sync.Mutex - page *tmdb.MediaPage - externalIDs *tmdb.ExternalIDs - externalIDsByID map[int]*tmdb.ExternalIDs - externalIDCalls []int - detail *tmdb.MediaDetail - discoverPage *tmdb.MediaPage - discoverErr error - searchMediaType string + mu sync.Mutex + page *tmdb.MediaPage + externalIDs *tmdb.ExternalIDs + externalIDsByID map[int]*tmdb.ExternalIDs + externalIDCalls []int + detail *tmdb.MediaDetail + discoverPage *tmdb.MediaPage + discoverErr error + searchMediaType string + gotDiscoverParams tmdb.DiscoverParams } func (f *fakeTMDBClient) SearchMedia(_ context.Context, mediaType, _ string, _ int) (*tmdb.MediaPage, error) { @@ -2078,7 +2080,10 @@ 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) { +func (f *fakeTMDBClient) DiscoverPage(_ context.Context, _ string, params tmdb.DiscoverParams, _ int) (*tmdb.MediaPage, error) { + f.mu.Lock() + f.gotDiscoverParams = params + f.mu.Unlock() if f.discoverErr != nil { return nil, f.discoverErr } @@ -2102,12 +2107,46 @@ func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.Med return f.detail, nil } +// certTMDBClient layers GetCertification onto fakeTMDBClient so a service +// under a rating ceiling can hydrate certifications. Kept separate from +// fakeTMDBClient so tests without certifications pin that the plain client +// does NOT satisfy TMDBCertificationClient. +type certTMDBClient struct { + fakeTMDBClient + certs map[int]string // tmdb id -> certification + certErr error + certCalls atomic.Int64 +} + +func (f *certTMDBClient) GetCertification(_ context.Context, _ string, id int) (string, error) { + f.certCalls.Add(1) + if f.certErr != nil { + return "", f.certErr + } + return f.certs[id], nil +} + type fixedCeiling struct{ q string } func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { return f.q, nil } +// ratedCeiling implements both EntitlementResolver and ContentRatingResolver. +type ratedCeiling struct { + q string + rating string + ratingErr error +} + +func (f ratedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { + return f.q, nil +} + +func (f ratedCeiling) MaxContentRating(context.Context, int, string) (string, error) { + return f.rating, f.ratingErr +} + // fakeRouterProvider is a canned RequestRouterProvider standing in for a // request_router.v1 plugin. Fulfill emits one target per requested quality // (unless noTargets is set), recording the qualities and connections it saw. diff --git a/internal/requests/types.go b/internal/requests/types.go index 699d655d..7c931871 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -100,6 +100,11 @@ type Settings struct { type FeatureStatus struct { RequestsEnabled bool `json:"requests_enabled"` + // RatingRestrictionsEnforced advertises that discovery/search results are + // filtered by the profile's max content rating and that over-ceiling + // detail (404) and create (403) requests are rejected. Additive v1 + // capability field so clients can feature-detect instead of version-sniff. + RatingRestrictionsEnforced bool `json:"rating_restrictions_enforced"` } type UserLimit struct {