feat(requests): enforce per-profile rating limits in discovery (#505)

* feat(requests): enforce per-profile rating limits in discovery

- Resolve each profile's max content rating and filter discovery, detail, and browse results against it, failing closed on missing ratings
- Reject request submissions for titles above the viewer's ceiling
- Add TMDB GetCertification backed by release_dates/content_ratings with a long-lived cache and singleflight
- Push certification.lte to TMDB for studio/network/genre browse as a cost pre-filter
- Backfill restricted section pages from a fixed window of TMDB pages to keep carousels populated and pagination stable

* fix(requests): address discovery rating review findings

- Preserve backfill overflow: sections use plain TMDB cursor semantics
  plus an additive next_page field instead of fixed windows, so an early
  stop never drops allowed titles from unconsumed pages (bit hardest at
  permissive R/TV-MA ceilings).
- Bound cold-path cost: DiscoverAll backfills at most 2 TMDB pages per
  section (vs 5 for a direct section request), capping worst-case cold
  certification hydration at 240 lookups instead of 600.
- Keep the TMDB prefilter a superset: rank-3 ceilings now push down
  certification.lte=NC-17/TV-MA rather than R, so titles the local
  ladder allows can't vanish upstream unrecoverably.
- Fail closed on foreign certifications: enforcement-path lookups use
  new US-only pickers (a Canadian PG no longer reads as US PG), while
  the display path keeps its any-country fallback. US multi-entry
  disagreements prefer the theatrical/real rating over festival NR.
- Detach shared certification fetches from the first caller's context
  (WithoutCancel + 30s bound) so one disconnecting client can't fail
  the singleflight result for concurrent waiters.
- Advertise enforcement via rating_restrictions_enforced on
  /requests/status so clients can feature-detect instead of
  version-sniffing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(requests): harden rating enforcement per second review pass

- GetDetail gates on the US-only enforcement certification (cached
  GetCertification) instead of the display rating, whose any-country
  fallback let a foreign "PG" pass the US ladder.
- pickUSMovieCertification takes the strictest recognized US rating when
  multiple release entries disagree ([PG, R] -> R); entry order is not
  meaningful and enforcement must not admit a title on its most lenient
  certificate.
- Certification singleflight uses DoChan so a canceled caller returns
  ctx.Err() immediately instead of blocking up to 30s on the detached
  shared fetch (which still completes for surviving waiters).
- Viewer rating ceiling resolves once per request and threads through
  discover/browse/detail enrichment (enrichPageWithCeiling); DiscoverAll
  drops from 12 scope resolutions per load to 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-27 22:34:30 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 1a784a1cdf
commit 54e184df85
10 changed files with 1475 additions and 39 deletions
+19 -5
View File
@@ -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.
+19 -5
View File
@@ -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 {
+152
View File
@@ -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 {
+252
View File
@@ -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)
}
}
+82 -9
View File
@@ -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 == "" {
+19 -5
View File
@@ -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,
})
}
+559
View File
@@ -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)
}
}
}
+319 -5
View File
@@ -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
+49 -10
View File
@@ -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.
+5
View File
@@ -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 {