Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/api/v2/fixtures/get_system_info_ok.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"server_version": "unavailable",
"api_major": 2,
"contract_digest": "d9c6f3495ff0b0d10ae5f5a562da2fb80e4bf884c7ab7b74d67f644672fb9551",
"contract_digest": "dd0147f28334dbd44f4f2bbac59eb4f2c00151e93c9004cb557b7b9a60fd56c2",
"links": {
"openapi": "/api/v2/openapi.json",
"capabilities": "/api/v2/capabilities",
Expand Down
6 changes: 6 additions & 0 deletions contracts/api/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -35035,6 +35035,9 @@
"expires_at": {
"type": "string"
},
"inventory_url": {
"type": "string"
},
"plan_attempt_key": {
"type": "string"
},
Expand Down Expand Up @@ -35080,6 +35083,9 @@
"timeline": {
"$ref": "#/components/schemas/TimelineV3"
},
"tracks_pending": {
"type": "boolean"
},
"transformations": {
"items": {
"$ref": "#/components/schemas/TransformationV3"
Expand Down
56 changes: 56 additions & 0 deletions docs/playback-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,62 @@ release's own; a rotated session whose new release is not yet probed reports
`inventory_status: declared` and that release's declared (possibly empty)
tracks, never the previous release's.

### Deferred track inventory

A cold start normally resolves a virtual source from provider-declared metadata
and upgrades it with a full ffprobe enumeration. On `/api/v2` a client that
advertises `deferred_track_inventory_v1` may instead receive a plan whose
first-byte URL is already playable while the enumeration runs after the
transport commit. The plan carries `tracks_pending: true` and
`inventory_url`; the menu is provisional until the follow-up lands. The frozen
`/api/v1` surface and a v2 client that did not advertise the feature never see
`tracks_pending` and keep the pre-existing behavior, so the deferred lifecycle
is strictly opt-in.

`inventory_url` is not gated by the negotiation: it is present on every fresh
plan, negotiated or not. It names the same live-inventory endpoint
`getPlaybackInventory` serves, and v2 clients have always been able to poll it.
What the negotiation adds is the promise that the menu may be provisional, which
is exactly what `tracks_pending` signals. A start that does not negotiate the
lifecycle still probes its source (inline when the start is synchronous, in the
background otherwise) and does not gate the menu on that probe; if it polls
`inventory_url` anyway, it simply observes `declared` until the probe lands.

The lifecycle is start-only. `replanPlayback` never defers: a replan either
replays the attempt's plan — including its `tracks_pending` marker — or runs its
own synchronous resolve, and it always carries `inventory_url`. A client must
therefore read `tracks_pending` from a replan response too, not assume the
lifecycle ended at start.

The session's inventory status moves `declared` → `pending` → `verified` or
`failed`. `pending` means the enumeration is outstanding; the client keeps the
provisional menu. `verified` means the enumerated tracks are committed and the
follow-up `inventory_updated` push (or a poll of `inventory_url`) replaces the
menu. `failed` is terminal: the enumeration could not be completed — a probe
error, an unreadable evidence write, or a saturated worker pool that could not
admit the probe — and the client must leave the loading state and keep the
provisional menu it already holds. A failed upgrade never changes the stream or
the executable audio selection; a viewer who picked an audio track keeps it.
When the durable evidence write commits a verified inventory whose order differs
from the declared one, the server replays the attempt's persisted audio
selection intent against it (the same default-audio reconciliation the
non-deferred evidence path runs) and asks the client to replan through the
existing `plan_invalidated` handshake, so the executable recipe keeps the
preferred language. An explicit viewer selection is verified present and never
overridden.
`tracks_pending` is a menu hint and is excluded from plan identity, so an
idempotent start retry replays the same plan with the same marker.

Apple and Android clients that adopt the feature must: read `tracks_pending`
from the start and replan responses and render the track menu as loading while
it is true; consume the existing `inventory_updated` realtime event
(`inventory_updated_event_v1`) or poll `inventory_url` (honoring its `ETag` /
`If-None-Match`) until the status is terminal; adopt the enumerated menu on
`verified`; on `failed`, stop showing the loading state and keep the current
menu and audio selection without issuing a replan; and drop the marker on any
plan replacement. Until they do, they must not send
`deferred_track_inventory_v1`.

## Capabilities

The response is `{installation_id, revision, state, allowed, protocol_versions,
Expand Down
102 changes: 102 additions & 0 deletions internal/api/handlers/playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,104 @@ type PlaybackHandler struct {
subtitleSlotsOnce sync.Once
subtitleSlots *virtualDetachedGate

// deferredProbeOnce guards lazy construction of the bounded post-commit
// probe pool (see enqueueDeferredVirtualProbeV3). A fresh-start resolve
// hands its deferred full-track enumeration to this pool instead of probing
// inline, so a saturated aggregate gate becomes backpressure that leaves the
// client in the provisional inventory state, not a synchronous probe on the
// first-byte path. The queue and workers are fixed; admission never spawns a
// goroutine per request.
deferredProbeOnce sync.Once
deferredProbeQueue chan *virtualDeferredProbeV3
deferredProbeWG sync.WaitGroup
// deferredProbeRetry parks probes that arrived while the queue was full.
// A worker drains one per completed probe, so a saturated burst completes in
// bounded steps without a second worker pool and without a synchronous probe
// on the request path.
deferredProbeMu sync.Mutex
deferredProbeRetry map[string]*virtualDeferredProbeV3
// deferredProbeSignal wakes an idle worker to drain the retry set. It is
// buffered (size 1) and never blocks the request path.
deferredProbeSignal chan struct{}
// deferredPublishOnce guards lazy construction of the single
// terminal-notification dispatcher (see startDeferredPublishDispatcher).
// One goroutine per handler, tied to the service context.
deferredPublishOnce sync.Once
// deferredPublishMu guards deferredPublishPending, deferredPublishRescan,
// and deferredPublishInFlight.
//
// LOCK ORDER: deferredPublishMu is always the OUTER lock. A caller may
// hold deferredPublishMu and then call a session-manager method (which
// takes the session lock internally — see the watermark check inside
// parkDeferredProbePublish and the rescan's per-candidate re-validation);
// the reverse — session lock held while acquiring deferredPublishMu —
// never happens, and every code path here must keep it that way.
//
// deferredPublishPending is the bounded set of terminal inventory
// notifications still owed a push, keyed by session ID so a later terminal
// state for the same session coalesces onto one entry. An entry lands here
// by one of three routes: a park when the aggregate detached gate was full, a
// retryable attempt (a transient catalog/plan-store read or a realtime write
// that did not reach the client) re-parked with a bounded backoff, or a
// selected intent returned by the failed-acquisition repark after the gate
// claim lost the race for a slot. The value carries the file whose inventory
// the push re-reads, the binding generation the notification acks after
// publish, and — for a retryable entry — the attempt count and the
// not-before instant that hold it out of selection until its delay elapses.
// Coalescing rule: a park replaces the parked entry only when the new
// generation is at least the parked
// generation, and is dropped entirely when the session's notified watermark
// already covers the new generation — an older or already-delivered intent
// can never overwrite a newer outstanding one. deferredPublishRescan is the
// coalesced overflow flag: a park, a retry-insert, or a failed-acquisition
// repark that finds the map full sets it rather than dropping intent, and
// the dispatcher re-parks the overflow from the session manager as soon as
// the map has room (not only when it drains — a retryable entry can keep it
// occupied while it waits out its backoff). The map is the record of what to
// publish; the signal channel is only a wake hint, so a lost signal can never
// strand a parked notification — the dispatcher always re-reads the map
// after any wake.
//
// Dispatchability predicate: a parked entry is dispatchable only while its
// session is not in deferredPublishInFlight, the session's notified
// watermark does not already cover the entry's generation, and — for a
// retryable entry — its not-before instant has passed. The dispatcher
// re-checks all three at selection time under this mutex, so a session
// mid-publish is never handed a second worker, an already-delivered
// generation is never republished, and an entry in retry backoff never spins
// a batch.
deferredPublishMu sync.Mutex
deferredPublishPending map[string]deferredPublishIntent
deferredPublishRescan bool
// deferredPublishInFlight tracks sessions whose selected intent was popped
// by a batch and handed to a publish worker but whose attempt has not yet
// recorded its final disposition (an ack, or a re-park on a retryable
// outcome). Selection deletes the map entry before the gate claim, so a
// session is briefly neither parked nor in-flight between the two; the
// claim's re-validation and the failed-acquisition repark close that window.
// A session in this marker's window still reads as "outstanding" to the
// overflow rescan (its generation is newer than the delivered watermark,
// which only advances on the worker's post-publish ack), so without this
// guard the rescan would re-park it and a second worker would publish the
// same notification again — a duplicate push. The rescan skips in-flight
// sessions; the marker is cleared after the disposition is recorded, so a
// session whose attempt finished is eligible for the rescan only if it
// genuinely still owes a notification (a re-parked retryable entry is
// covered by its own parked intent, not this marker).
deferredPublishInFlight map[string]struct{}
// deferredPublishSignal is the buffered-1 wake hint for the dispatcher. A
// park sends it nonblocking; a full buffer is fine because the dispatcher
// re-checks the map regardless of how many signals it coalesced.
deferredPublishSignal chan struct{}
// deferredPublishDone is the buffered-1 completion signal: a publish
// worker sends it nonblocking when it clears its in-flight marker, so a
// dispatcher that found every parked entry in-flight (nothing dispatchable)
// is woken the moment one finishes instead of busy-looping batches against
// the in-flight set or waiting a full backstop tick. Coalescing is safe for
// the same reason as the park signal: the dispatcher always re-reads the
// map after any wake.
deferredPublishDone chan struct{}

// prefetchOnce guards the lazy prefetch worker pool. Prefetch work is
// admitted into a bounded queue (prefetchQueue) before any goroutine
// handles it, deduplicated by source+profile equivalence key
Expand Down Expand Up @@ -1932,6 +2030,10 @@ func (h *PlaybackHandler) finalizeSessionStopWithResult(ctx context.Context, ses
ctx = context.Background()
}
h.cancelPlaybackStartSideEffectsV3(ctx, session.ID)
// The session is gone: a terminal inventory notification parked behind a
// full detached gate would re-publish against a session ID the manager no
// longer knows, so discard it rather than let the drain retry a dead session.
h.dropDeferredPublish(session.ID)

stopResult := h.recordStopHistory(ctx, session)
if h.WatchScrobbler != nil {
Expand Down
Loading
Loading