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.
What changed, in one picture
Section titled “What changed, in one picture” 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.
The pieces
Section titled “The pieces” 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).
Where each play path asks for it
Section titled “Where each play path asks for it” 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
preparehook from doc 8 now callsheadersFor(). Every load goes through it: the first play, the next episode, a replay, and the retry after an error. - Chromecast.
_dispatchLoadwaits 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
preparehook. Swapping back to direct now awaitsheadersFor(). 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.
Why a 15-second window
Section titled “Why a 15-second window”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 #2Only 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.
When the fetch fails
Section titled “When the fetch fails”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.