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:
+19
-5
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user