Skip to content

9. A Fresh Entitlement Token on Every Play

9. A Fresh Entitlement Token on Every Play

Section titled “9. A Fresh Entitlement Token on Every Play”

Superseded by doc 10 (2026-09-30). Plays no longer fetch on every play. The cached token is used until it nears exp. prime(), ensureFreshEntitlement(), the 15 s window and the 30-minute background renew are gone. This doc is kept as history.

Branch feat/entitlement-per-play (2026-09-28). This builds on doc 8. Doc 8 made the headers get written at load time. This doc makes the entitlement token inside them come straight from the server on every play, not from a cache.

BEFORE (doc 8) AFTER
────────────── ─────
play → ensureToken() play → ensureFreshEntitlement()
│ │
├─ cached & not expired? use it ├─ fetch already running? join it
└─ else fetch ├─ fetched < 15 s ago? use it
└─ else fetch (GET /v1/user/entitlements)

ensureToken() only went back to the server when the cached token’s JWT had expired. /v1/user/entitlements can say “no” long before exp does, for example when a subscription lapses or a rental ends. The app kept sending the old “yes” until the next expiry or the next 30-minute background. After this change each play asks the server again.

lib/domain/services/playback_auth_resolver.dart ← the contract (pure Dart)
lib/data/services/playback_auth_resolver_impl.dart ← the one place that decides
"fresh or cached"
│ uses
├── EntitlementService.renew() forced fetch, keeps the old token if it fails
├── EntitlementService.ensureToken() cached path (trailers only)
├── ContentAccessTokenService per-title token (unchanged, doc 8)
└── EncryptionHelper.playerHeader builds the five KMS headers
Method What it does
prime() Starts a fresh fetch and does not wait for it. It is called on the Play tap.
ensureFreshEntitlement() Joins a running fetch, reuses one from the last 15 s, or starts a new one.
headersFor(content) Fresh entitlement and the per-title token, in parallel, then the headers.
headersFor(content, freshEntitlement: false) Same, but keeps the cached entitlement. Used by trailers and shorts ads.
keyHeaders(contentAccessToken:) Fresh entitlement plus a given title token. Used by downloads, which have no ContentEntity.

It is registered in GetIt as PlaybackAuthResolver. It replaces the old kmsHeadersFor global, which tests used to overwrite. Tests now pass a fake in (test/helpers/fake_playback_auth.dart).

Play tap ─► CastAwareNavigation.play ─► prime() ← the fetch starts here, early
│
┌───────────┼───────────────────────┬──────────────────────┐
▼ ▼ ▼ ▼
phone player Chromecast AirPlay download
prepare hook _dispatchLoad awaits direct: prepare hook start() and
in buildPlayer ensureFresh...() swap-to-direct awaits reenqueueMissing()
Media + proxy key miss headersFor() await keyHeaders()
proxied: proxy key miss
  • Phone player. The prepare hook from doc 8 now calls headersFor(). Every load goes through it: the first play, the next episode, a replay, and the retry after an error.
  • Chromecast. _dispatchLoad waits for a fresh token before it sends the receiver anything. The TV plays the rest of the queue by itself, without asking the app, so the LAN proxy also asks for a fresh token whenever a new key is requested (beforeKeyFetch). A key the proxy has already cached does not trigger a fetch.
  • AirPlay. Direct mode uses the same prepare hook. Swapping back to direct now awaits headersFor(). Proxied mode uses the proxy hook.
  • Downloads. The key headers are built with a fresh token when a download starts, and again when the OS re-queues missing segments days later. Doc 8 listed that second case as a gap.
  • Trailers and shorts ads keep the cached token on purpose.

One play touches the resolver more than once: the tap (prime), the player’s prepare, and for cast the dispatch and then the proxy’s key request. Without the window, each of those would be its own network call.

t=0.0s tap prime() → fetch #1 starts
t=0.4s prepare ensureFresh...() → joins fetch #1
t=0.6s fetch #1 returns → window stamped
t=2.0s proxy key ensureFresh...() → inside window, no call
─────────────────────────────────────────────────────────────
t=45 min next episode prepare → outside window → fetch #2

Only a successful fetch stamps the window, so a failure never stops the next play from trying again. Two plays of different titles less than 15 s apart share one fetch. That is acceptable: nothing about the user’s entitlements changes in 15 s.

The play goes ahead on the cached token. renew() only replaces the global entitlement_token after a successful fetch, so a timeout (6 s) or an error leaves the last good token in place. This matches the decision to “fall back to cached”. The retry paths from doc 8 still apply: the one-shot reload on the phone, and the 403 → refresh() → retry in the cast proxy.

A signed-out user makes no request and sends no x-entitlement, as before.

Each play now makes one extra GET /v1/user/entitlements. The Play tap starts it while the route is pushed and the screen rotates, so on a normal connection it has finished before the player needs the headers. On a slow connection, the first frame can wait for it, for at most 6 s.

  • test/data/services/playback_auth_resolver_test.dart: covers the window, sharing one fetch, fallback on failure or timeout, trailers, signed-out users and download headers.
  • test/data/services/cast/cast_lan_proxy_server_test.dart: a key miss asks for a fresh token, a cached key does not, and a failing prefetch still serves the key.
  • test/presentation/widgets/player/cast_controller_test.dart: LOAD waits for the fresh token.
  • test/presentation/widgets/player/player_media_builder_test.dart: the player asks for fresh; trailers do not.

Each of these was checked by mutation. Swapping renew() back to ensureToken(), removing the window stamp, stamping on failure, removing the proxy hook, or removing the cast dispatch await each turns a test red.