* docs(playback): plan protocol v3 server implementation * docs(playback): incorporate protocol v3 review * feat(playback): implement protocol v3 server * fix(playback): persist empty route diagnostics * feat(playback): harden protocol v3 HDR routing * feat(playback): complete protocol v3 client contract * fix(playback): harden protocol v3 recovery * fix(playback): restore dovi_rpu strip filter for DV remuxes The v3 work renamed the Dolby Vision strip recipe to a dovi_split=mode=bl bitstream filter that does not exist in stock FFmpeg or jellyfin-ffmpeg; the probe failed closed on every deployment, disabling the new validated DV7-to-HDR10 route and regressing the previously working dovi_rpu=strip=1 remux path from main. Restore dovi_rpu across the probe, remux and HLS copy arguments, and the recipe-card constant. Also from review: validate the remux DV mode for every profile (garbage modes on non-P7 sources silently no-opped), reject preserve mode for P7 outright (a base-layer-only remux cannot preserve dual-layer DV), tag dvhe sample entries only for the explicit v3 preserve recipe so legacy web/jellycompat remuxes keep their pre-v3 hev1 labeling, and honor the token-frozen DV mode in the proxy remux path instead of legacy-auto. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): correct v3 planner policy and contract validation Review fixes to the v3 planner and wire contracts: - Bar Profile 7 sources from the non-strip progressive remux route: a base-layer-only remux can never deliver native dual-layer DV, so the planner no longer emits plans claiming validated Dolby Vision while the executed remux drops the enhancement layer. - Accept the device-quirks feature flag from either capability location, matching every other dual-location feature check. - Treat legacy hdr_unknown rows as HDR10 for HDR10-capable clients with a degradation warning instead of leaving them unplayable under v3. - Honor bandwidth_cap_kbps as a hard ceiling in every quality mode and wire the previously dead Metered signal into conservative auto rungs. - Degrade to the validated source-quality route instead of a terminal when only an implicit quality reduction demanded an unsupported transcode; explicit user-selected rungs keep terminal behavior. - Bound inner capability lists and strings; compare attempt keys exactly instead of case-folded; make ParseTrackIDV3 strict about canonical numerics; accept dvdsub/pgssub/dvbsub aliases and stop promising burn-in for unknown subtitle codecs; probe every h264 encoder rather than requiring libx264; normalize the file-level bitrate fallback. - Evaluate subtitle renderability against the engine each candidate route executes on, not always media3_direct. - Pin the with-quirks attempt-key preimage arity in the cross-language fixture so the Kotlin client stays in lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden v3 control-plane reliability Review fixes to the v3 session, store, and handler layer: - Bound concurrent replans with a slot semaphore: each replan pins a pooled connection for its advisory lock while issuing further store queries from the same pool, so an unbounded recovery storm could turn every connection into a lock holder and deadlock the server. - Make CompleteReplan a real compare-and-swap (base-revision predicate, ErrReplanSupersededV3) and map BeginReplan insert races to a replay instead of a raw unique violation. - Fingerprint start requests (request_digest column): an attempt ID reused with different input is now a 409-style conflict rather than a silent replay, and both replay paths check session liveness so dead sessions surface as retryable terminals. - Pre-delete expired attempt rows on SaveAttempt so a retry during the cleanup window cannot wedge on an unreachable conflict. - Align the in-memory store's semantics with Postgres and add DB-backed planstore tests (SILO_TEST_DATABASE_URL), including a regression test inserting every route-event name against the real CHECK constraint. - Session manager: v3 route-set updates own RemuxDVMode outright so a replan onto an SDR source clears a stale strip mode; replacement reservations survive unrelated legacy stream updates; replacement admission excludes the replaced session explicitly instead of decrementing totals it may no longer be part of; the admission CAS loop is bounded and decider errors are logged. - Map transient store failures to 500s instead of terminal 404/403s; authorize route events via identity-only projections after the rate limiter; keep sanitized diagnostics deterministic. - Merge the server-computed durable plan key into replan exclusions so unreproducible client history cannot re-select the failed route. - Remap tracks only when the effective edition changes (a same-file replan no longer switches audio to a lookalike track) and remap ID-only subtitle selections on edition fallback. - Cache the v3/shadow feature flags for five seconds instead of one settings SELECT per playback request; stop remote transports best-effort when the start call times out; carry dvm/tid claims and the transport-scoped job identity through the legacy audio-change re-mint; index playback_route_events(received_at) for the retention delete; run store maintenance for DB-less deployments too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transcode): reap idle node jobs and gate WebVTT conversion - Add an idle reaper to the transcode node: a job untouched by manifest or segment requests for ten minutes is closed and unregistered. After a v3 replan retires a transport ID, a stale in-flight stream token could resurrect the old job via reconstruct and encode to end-of-file for nobody; jobs waiting on readiness count registration as access and are never reaped mid-wait, and reaping keeps the recipe so a still-valid token reconstructs on the next hit. - Reject bitmap subtitle tracks (PGS) on the .vtt conversion path with 415 before headers are written instead of spawning an ffmpeg command that always fails mid-response, and make the extract-format override fall back to source-driven mapping for bitmap codecs. - Drain error bodies on non-202 node responses so the HTTP transport can reuse connections. - Pin the transcode-dir cleanup separator-boundary semantics with a regression test (a session ID sharing another's prefix must not retain foreign directories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): close v3 planner policy gaps from review - Clamp the final transcode bitrate to bandwidth_cap_kbps: the ladder has no rung below 480p/1500kbps, so lower caps were silently exceeded even though the cap is documented as a hard delivery ceiling. - Treat video-only media as audio-compatible instead of forcing an AAC conversion (or an audio_conversion_unsupported terminal) onto a file with no audio stream. Tracks whose codec failed to probe keep the gate. - Only promise a bitmap subtitle sidecar for embedded PGS with an engine that renders embedded bitmap: external/downloaded bitmap and embedded DVD/DVB published artifact URLs that always failed at fetch. They now fall through to burn-in or its terminal. - Accept client_video_transformations_v1 from either client_features or the nested context when validating client-executor transformations, matching the planner's dual-source reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): probe and execute DV remuxes with one ffmpeg binary The v3 transformation registry probed the configured playback.ffmpeg_path while progressive remux execution resolved the process-global discovery path, so a deployment where only one binary carries dovi_rpu could plan a server_dv7_to_hdr10 route and then fail it at stream time. Resolution now goes through a shared ResolveFFmpegPath (configured path first, discovery fallback — the same rule the transcode pipeline already used), the dovi_rpu probe is cached per binary path, and the stream handler and proxy worker pass their configured path into ServeRemuxWithDVMode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden v3 replan identity and control-plane limits - Seed failure-replan track selections from the durable current plan before overlaying the request: after an alternate-version fallback the normalized request still carries requested-edition track IDs, so a replan omitting unchanged tracks was rejected as a track/file mismatch. - Remap ID-only audio selections across edition changes (parse the ID to an index like the subtitle remap already does) instead of leaving a stale file-bound ID to fail validation. - Release the node planner reservation when a prepared remote transport rolls back after the node accepted the job; repeated failed starts could otherwise pin max-job/bandwidth budgets for the full reservation age. - Size the replan semaphore below the PostgreSQL pool via a store capacity advisor: with max_connections at or below the fixed bound, advisory-lock holders could starve the inner store queries they need to finish. - Contain shadow-planner panics with a recover boundary; it runs on a bare goroutine where an escaped panic kills the process for what is telemetry-only work. Document why the memory store's session lock is deliberately a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transcode): serialize node job teardown against reconstructs - Look up and touch manifest/segment sessions in one critical section so the idle reaper cannot unregister a job between the lookup and its liveness refresh. - Re-validate each reap candidate under the per-session lifecycle lock before closing it: Close removes the output directory, and without the lock it could race a token reconstruct and wipe the segments the fresh ffmpeg is writing. - Take the lifecycle lock in handleStop so a stop racing a RequireReady start's readiness wait blocks until registration and tears the job down, instead of 404ing and orphaning the ffmpeg until the reaper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
36 KiB
Playback Protocol v3 Server Implementation Plan
Status: Proposed
Scope: silo-server, with coordinated contract fixtures in silo-android
Protocol owner: Silo server
Client target: Android Media3-only playback; legacy web and Apple playback remain supported
1. Goal
Implement the server half of Silo playback protocol v3 so a client can report its current device and output capabilities and receive one complete executable plan for:
- authenticated original HTTP playback;
- progressive or HLS remux;
- video-copy with audio adaptation;
- HLS video transcode;
- subtitle render, conversion, or burn-in; or
- a terminal
adaptation_unavailableoutcome.
The client must never choose FFmpeg recipes. The server must never claim that a route preserves Dolby Vision, HDR, lossless audio, or subtitle fidelity unless the source metadata, client/output capability, selected recipe, and installed tooling support that claim.
The normative behavior remains in the sibling Android repository:
silo-android/docs/playback/01-media3-only-player-architecture.mdsilo-android/docs/playback/02-migration-compatibility-validation.mdsilo-android/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt
This document maps that contract onto the current server. It does not redefine the protocol.
2. Verified server baseline
The implementation should reuse the current playback stack rather than replace it:
| Existing behavior | Current owner | v3 implication |
|---|---|---|
| Legacy start, authorization, profile checks, track preferences, session creation | internal/api/handlers/playback.go |
Preserve the legacy branch byte-for-byte where possible; move shared operations behind helpers. |
| Direct/remux/transcode choice | internal/playback/resolver.go |
Replace its broad codec/container booleans with a v3 planner for v3 requests only. Do not change legacy decisions during the first rollout. |
| Byte-range original delivery | internal/playback/directplay.go, internal/api/handlers/stream.go |
Reuse as original_http. |
| Progressive MP4 remux and audio-to-AAC | internal/playback/remux.go, internal/api/handlers/stream.go |
Reuse as server_remux_progressive; make every transformation explicit in the plan. |
| HLS copy/transcode, timelines, restart/reconstruct | internal/playback/transcode.go, internal/playback/transcode_manager.go |
Reuse as server_remux_hls or server_transcode_hls. |
| Local and remote transcode start | HandleStartTranscode, internal/transcodenode/server.go, internal/nodepool |
Extract a server-callable starter so v3 can return the final manifest without a second client recipe request. |
| Playback sessions, policy and limits | internal/playback/session.go |
Extend session state with v3 attempt/plan identity; keep current admission controls authoritative. |
| Source video/audio/subtitle metadata | internal/models/media.go, internal/scanner/probe.go |
Most required fields already exist, including DV profile, BL compatibility ID, EL presence, color/range, bit depth, layout, and channels. MEL/FEL classification and transformation provenance are not yet represented. |
| Subtitle extract/conversion/burn-in | internal/playback/subtitles.go, subtitle_stream.go, transcode arguments |
Reuse the executors; add an explicit policy decision and artifact contract. |
| Restart-safe signed stream recipes | internal/playback/recipecard.go, internal/streamtoken |
Continue using signed recipe tokens; v3 plans describe the resulting URL and timeline. |
The largest correctness gaps in the current resolver are expected and must not be carried into v3:
- transcode-disabled incompatibility currently falls back to attempting direct playback instead of returning a terminal result;
- codec support is modeled as a string union, without profile, level, bit depth, frame rate, range, sink layout, or subtitle-fidelity validation;
- clients currently supply transcode codec, bitrate, segment, and burn-in
recipe details through
/playback/transcode/start; - the start response does not contain an authoritative effective recipe, validation claims, transformations, or timeline for every delivery;
- no idempotent replan or attempt-scoped route-event API exists.
3. Architecture decision
3.1 Additive dispatch on the existing start endpoint
Keep POST /api/v1/playback/start for compatibility. Refactor its handler into
a small protocol dispatcher:
- Decode only an envelope containing
protocol_version. - When it equals
3, decode strictly asPlaybackStartRequestV3and call the v3 orchestration service. - When it is absent or not
3, pass the original body to the existing legacy decoder and behavior.
Use buffered request bytes or json.RawMessage; never decode the body twice
from the socket. Unknown fields remain tolerated for additive compatibility,
but required v3 fields and closed enum values are validated explicitly.
Do not overwrite the existing request/response structs with a union of legacy and v3 fields. That would make missing nested capabilities silently look like a valid legacy request and recreate the current false-direct failure mode.
3.2 Introduce one v3 orchestration service
Add a service in the internal/playback domain that owns:
- input normalization and validation;
- stable track resolution;
- source and capability evaluation;
- ordered candidate construction;
- policy and capacity checks;
- deterministic plan identity and loop prevention;
- transport startup;
- terminal outcome mapping;
- replan idempotency; and
- plan/session state transitions.
HTTP handlers authenticate, authorize, decode, call the service, and encode the result. They do not contain route-selection rules.
Recommended files:
internal/playback/protocol_v3.go wire/domain types and enums
internal/playback/plan_v3.go candidate planner
internal/playback/capabilities_v3.go source/capability validators
internal/playback/recipe_v3.go normalized executable recipes
internal/playback/plan_key_v3.go canonical IDs and attempt keys
internal/playback/tracks_v3.go stable track identities/remapping
internal/playback/subtitle_policy_v3.go subtitle decision policy
internal/playback/transformations_v3.go installed-tool/recipe registry
internal/playback/protocol_v3_test.go
internal/playback/testdata/protocol_v3/ golden contract/planner fixtures
internal/playback/planstore/store.go persistence interfaces
internal/playback/planstore/postgres.go attempts, replans and route events
internal/api/handlers/playback_v3.go start/replan/event handlers
internal/api/handlers/playback_transport.go reusable transport starter
internal/api/handlers/playback_v3_test.go
If implementation reveals that the wire and domain structs need different
types, keep JSON structs in handlers and convert at the boundary. Do not add
JSON tags throughout unrelated session and FFmpeg types merely to save mapping
code.
3.3 Reconcile additive client-contract gaps before freezing v3
The current Kotlin v3 types are sufficient to negotiate and execute a plan, but they do not yet carry every fact the normative direct-play rules require. The server cannot manufacture those facts. Phase 0 therefore includes a coordinated additive Android contract change:
| Current gap | Required additive contract |
|---|---|
| Video capability is a codec-name list plus a global maximum resolution. | Add per-codec decode entries with supported profiles/levels, bit depths, maximum width/height/frame rate/bitrate, and hardware/software status. |
| Audio passthrough is a codec-name list plus one global maximum channel count. | Add per-codec supported channel counts/layouts; retain the old fields for tolerant readers. |
| A plan does not expose requested versus effective media file when alternate-version selection occurs. | Add requested/effective media file IDs and a normalized source descriptor to the plan. |
| Subtitle decision does not expose the effective fidelity policy. | Add subtitle_fidelity_policy beside mode/artifact. |
| Route events cannot explicitly report replan identity, local PCM recovery, retry outcome, decoder timing, or requested/effective quality. | Add optional structured fields or a versioned, bounded diagnostic schema; the server enriches delivery/recipe/source facts from stored plan state rather than trusting duplicates from the client. |
Android's profile-quality canonicalizer passes unknown stored labels through and does not produce original. |
Canonicalize the closed v3 values and aliases client-side; keep server normalization tolerant for rolling upgrades. |
Gate the detailed behavior with additive client feature tokens such as
detailed_decode_capabilities, layout_aware_passthrough, and
playback_route_diagnostics. A v3 client missing them remains parseable, but
unknown profile/layout facts cannot satisfy a strict direct claim. The planner
must choose a conservative adapted route or terminal result.
Freeze the exact JSON field names only after Go/Kotlin golden fixtures exist. Do not implement a server-only guess and call the contract complete.
The response engine mapping is fixed:
| Delivery | engine |
|---|---|
original_http |
media3_direct |
server_remux_progressive |
media3_progressive_remux |
server_remux_hls |
media3_hls |
server_transcode_hls |
media3_hls |
Android currently accepts header refresh modes none and session; do not
emit refresh_endpoint until the client implements it.
Never emit legacy/client-owned runtime values in a v3 plan:
mpv_direct, client_local_loopback, external_player, or
client_local_normalization. They remain decode-only compatibility values on
Android.
3.4 Keep v3 dark until the complete route is executable
Add a dynamic server setting playback.protocol_v3_enabled, default false.
Read it through PlaybackSettingsReader.Get on each capability/start request,
as the existing allow_4k_transcode path does. Do not load it only through
internal/config/db_loader.go; that would turn rollback into a restart-required
change.
Also add GET /api/v1/playback/capability because /api/v1 features use
capability detection rather than server-version sniffing.
When a v3 start arrives while disabled, return a successful negotiation
response with no allocated session and without playback_plan_v3 in
server_features. This intentionally triggers Android's
server_upgrade_required path without leaking a legacy session.
The capability response should contain:
{
"enabled": false,
"protocol_versions": [3],
"features": [],
"deliveries": [],
"transformations": [],
"reason": "disabled"
}
Only advertise playback_plan_v3 after every enabled delivery and
transformation passes Phase 0 fixtures on the deployed build.
4. HTTP contract and ownership
4.1 Endpoints
| Endpoint | Success | Responsibility |
|---|---|---|
GET /api/v1/playback/capability |
200 |
Feature/protocol and installed transformation discovery. |
POST /api/v1/playback/start |
201 |
Authorize source, create a session, select/start a route, return playable or terminal v3 response. |
POST /api/v1/playback/{session_id}/replan |
200 |
Idempotently replace the active plan at the supplied media position. |
POST /api/v1/playback/route-events |
202 |
Validate and enqueue attempt-scoped diagnostics. |
All four routes use normal account authentication. Start, replan, and route
events require profile auth. X-Profile-Id remains authoritative; a body
profile_id must match it. Replan additionally verifies session ownership and
that playback_attempt_id belongs to the session.
Playable and adaptation_unavailable are protocol outcomes, not HTTP errors.
Use HTTP errors only for transport/API failures such as malformed input,
unauthorized access, missing files, or an idempotency conflict.
4.2 Strict request validation
Reject before allocating a session when any of these fail:
- protocol is not exactly
3; playback_attempt_idis absent or not a bounded UUID/ULID-like identifier;- file/profile authorization fails;
- track ID and fallback index disagree;
- output-route generation is negative or disagrees with the nested output context;
- codec/container/feature lists exceed bounded counts or string lengths;
- bandwidth, bitrate, resolution, attempt count, or position is outside sane bounds; or
- a replan's failed plan is not the session's active plan.
Normalize codecs, containers, layouts, and dynamic-range labels once at the
boundary. Accept bounded known quality aliases such as 4k; fold an unknown
rolling-upgrade quality value to auto with a decision warning instead of
hard-failing playback. Preserve the original values only in bounded
diagnostics.
4.3 Complete response invariant
A playable response is valid only after its transport can be fetched or has
successfully entered its startup state. It includes:
- protocol and feature negotiation;
- stable
session_idand deterministicplan_id; - delivery and Media3 engine;
- final URL, stream protocol, container/MIME, scoped headers, and supported header-refresh mode;
- exact player/source timeline mapping and seek window;
- selected stable audio/subtitle identities;
- effective codec, range, dimensions, frame rate, bitrate, channels and layout;
- route validation claims;
- subtitle mode/artifact;
- named transformations and degradation warnings; and
- a stable decision reason code.
Never return a placeholder transcode URL and expect v3 Android to call the legacy transcode-start endpoint.
5. Domain invariants
5.1 Stable tracks
Use the current Android-compatible identity initially:
file:{media_file_id}:audio:{ffmpeg_audio_ordinal}
file:{media_file_id}:subtitle:{combined_subtitle_ordinal}
Freeze the combined subtitle ordering used by buildSubtitleURLs:
- external subtitles in stored order, starting at zero;
- embedded subtitles at
len(external) + embedded_ordinal; skipped legacy bitmap entries retain their ordinal hole; and - downloaded subtitles in repository
created_atorder atlen(external) + len(all_embedded) + downloaded_ordinal.
The identity is paired with the effective media file ID. Centralize generation,
parsing, URL mapping, and alternate-version remapping in tracks_v3.go, and
freeze the exact inventory in Phase 0 fixtures. A plan that switches file
version must return the effective file's identity after matching the requested
track by signature. Do not echo a requested-file track ID with an
effective-file index.
Longer term, scanner-persisted immutable stream IDs can replace ordinal IDs in a future protocol version; do not change v3 identity semantics after release.
5.2 Deterministic plan IDs and loop prevention
The Android attempt key currently includes plan_id. Therefore a random plan
ID would let the same failed recipe reappear under a new key and defeat loop
prevention.
Derive plan_id deterministically within a playback attempt from:
playback_attempt_id;- effective file ID;
- delivery and stream protocol/container;
- normalized video/audio recipe;
- effective track IDs;
- subtitle mode/artifact type;
- sorted transformation names; and
- policy/recipe version.
Do not include expiring URLs, JWTs, timestamps, node hostnames, or the output
route generation in plan_id. The same effective recipe has the same plan ID;
an output-route change remains distinguishable because Android includes
output_route_generation in its attempt key.
Implement the client's FNV-1a 64-bit v3:<16-lowercase-hex> attempt-key
algorithm in Go. The UTF-8 canonical string is exactly:
plan_id
|KOTLIN_DELIVERY_ENUM_NAME
|KOTLIN_STREAM_PROTOCOL_ENUM_NAME
|lowercase_container_or_empty
|lowercase_video_codec_or_empty
|lowercase_audio_codec_or_empty
|(width_or_0)x(height_or_0)
|bitrate_kbps_or_0
|lowercase_dynamic_range_or_empty
|KOTLIN_SUBTITLE_MODE_ENUM_NAME
|comma_joined_transformation_names_sorted_by_name
|output_route_generation
|comma_joined_local_mutations_sorted_lexically
The newlines above are explanatory only; the hashed value is one string joined
by literal | characters. Enum components are Kotlin constant names such as
ORIGINAL_HTTP, HTTP_PROGRESSIVE, HLS, and BURN_IN, not lowercase wire
tokens. Hash with offset basis 0xcbf29ce484222325 and prime
0x100000001b3, then zero-pad to 16 lowercase hex digits.
Before committing a candidate, compute its base key for the request's output
route and reject it when present in attempted_plan_keys. Canonical fixtures
must be generated by the checked-in Kotlin implementation and consumed as
opaque expected values by Go tests; a Go-generated fixture would make the
parity test circular. Include transformation-order, empty/default-field,
output-route, and local PCM-mutation cases.
Server-side ordered candidate IDs are not security tokens.
5.3 Session and replacement semantics
Treat replan as one replacement transaction, serialized with the per-session lifecycle lock:
- validate policy, source, candidate and tooling without touching the active route;
- reserve the active session's existing stream/transcode capacity slot so the replacement is neither double-counted nor denied by its own predecessor;
- start the successor in a plan-scoped staging directory/transport generation, or under an explicitly replacement-linked new session ID;
- wait until the successor URL is fetchable or its startup state is validated;
- atomically commit effective file, tracks, recipe, plan ID, timeline and signed route;
- close and reap the predecessor; and
- release or transfer the capacity reservation exactly once.
If any pre-commit step fails, leave the old route and session usable and return a typed terminal/retryable result. Never run two FFmpeg writers against the same output directory. Progressive request-scoped transports may drain until the old client request disconnects; they do not require a destructive close-first swap.
Prefer retaining the same logical playback session so progress, scrobble and
admin history remain continuous. If the transport design requires a new public
session ID, return it explicitly and keep the old ID alive until the successor
is ready. Android will stop the old ID afterward and ignore stop failures. Keep
the legacy stop endpoint's behavior unchanged; internal v3 replacement and
cleanup paths must treat an already-stopped session as success even though the
current SessionManager.StopSession returns ErrSessionNotFound.
5.4 Replan idempotency
Create a timestamped Goose migration, generated with:
make migrate-create NAME=add_playback_protocol_v3
Add short-lived persistence for:
- playback attempt/session/current-plan state;
(session_id, replan_request_id)request digest, state, lease and serialized response; and- route events.
Current native restart reconstruction is carried by signed stream tokens, not a durable plan database. This plan store is the first persisted v3 control- plane state; it complements rather than replaces recipe tokens and therefore needs explicit expiry/cleanup and token-to-plan reconciliation tests.
For a duplicate replan ID:
- same digest and completed state returns the cached response;
- same digest and active lease waits on/single-flights the owner, then returns the response;
- different digest returns
409 idempotency_key_reused; and - an expired lease may be reclaimed after reconciling the session's current recipe/plan state.
Store only the minimum response needed for replay and expire it with the playback attempt. Never put source paths or authorization headers in route events. Signed URLs in cached responses inherit the attempt TTL and are removed by cleanup.
6. Planner rules
6.1 Candidate order
Build candidates in least-destructive order, but validate every candidate independently:
original_http;server_remux_progressivewhen the container alone is incompatible and progressive seek semantics are acceptable;server_remux_hlswhen HLS packaging is required;server_remux_hlswith copied video plus audio adaptation, with the audio change named in the effective recipe, transformations, claims and warnings;server_transcode_hls; and- terminal
adaptation_unavailable.
Remux is not mandatory before transcode. Skip directly to the first valid candidate. A selected subtitle may independently add an artifact or force burn-in.
6.2 Direct eligibility
Original delivery requires all of:
- validated source file and byte-range path;
- client container extractor support;
- selected video codec/profile/level/bit depth/resolution/frame rate/bitrate;
- compatible source dynamic range and current display output;
- selected audio local decode or exact sink passthrough by codec and channel layout;
- renderable selected subtitle at the required fidelity, or an independent converted artifact; and
- no requested quality reduction.
Missing metadata is unknown, not compatible. A probe repair may run before
planning, but the planner must terminal or adapt when required fields remain
unknown.
6.3 Quality policy
Move target selection out of clients. Add a pure QualityPolicy that maps:
originalto no user-requested reduction, while still permitting mandatory compatibility adaptation;- a fixed rung to a maximum height with no upscaling; and
autoto the lowest of device maximum, bandwidth estimate with safety margin, user cap, metered policy, and administrator constraints.
Start with one server-owned ladder. Reconcile it with the existing web constants
in web/src/player/hooks/useTranscodeQuality.ts; do not maintain two unrelated
bitrate tables. The v3 result returns the actual height/bitrate and decision
reason. Multi-variant ABR is explicitly outside this work.
6.4 Audio
Evaluate the selected track, not MediaFile.CodecAudio alone:
- exact sink passthrough codec plus channel count/layout permits audio copy and
a
passthrough=trueclaim; - client decode support permits copy with
passthrough=false; - otherwise use video-copy/audio adaptation when permitted;
- otherwise terminal with
transcoding_disabled,audio_conversion_unsupported, or policy/capacity reason.
The effective recipe must report the actual output channels/layout. Do not call AAC stereo output Atmos-preserving. DTS core extraction, E-AC-3 conversion, or other lossy/core-only changes are named transformations with warnings.
6.5 HDR and Dolby Vision
Use VideoTrack fields already populated by the scanner: DV profile, BL
compatibility ID, EL presence, range type, color metadata, bit depth, profile,
level, and dimensions.
Add an explicit enhancement-layer classification:
none | mel | fel | unknown
An analyzer interface may use an installed dovi_tool/libdovi implementation;
without it, Profile 7 with an EL remains unknown. Unknown must never be
reported as MEL, FEL, or validated native DV.
Represent transformations in a registry with:
- stable name and recipe version;
- required binary/filter/hardware capability;
- accepted source metadata;
- promised output range;
- argument builder;
- validation fixture; and
- failure-to-terminal mapping.
Initial entries should cover only paths proven by fixtures, for example:
dv_metadata_strip_to_hdr10using a compatible HDR10 base layer;dv_p7_to_p8_1only when the selected toolchain and fixture prove the output;hdr_to_sdr_tonemaponly when integrated and remote transcode paths both implement the same declared result.
FFmpeg's dovi_rpu bitstream filter can strip DV metadata without decoding,
and dovi_split can separate Profile 7 layers
(FFmpeg bitstream-filter documentation).
dovi_tool can inspect RPU metadata
and convert compatible streams. Probe these capabilities at startup and expose
only registered, available transformations. Do not silently run the current
Profile 7 strip path and still claim Dolby Vision preservation.
The current progressive remux path is not v3-safe unchanged: when dovi_rpu
is present it strips Profile 7 metadata automatically, while the no-filter path
leaves the copied bitstream without that named adaptation. Refactor remux input
to require an explicit transformation choice. A Profile 7 progressive remux is
eligible only when it either preserves a fixture-validated native P7 stream or
runs the registered dv_metadata_strip_to_hdr10 transformation against a
validated HDR10-compatible base layer. When the required filter/tool is
missing, disqualify that remux candidate rather than emitting an ambiguous
stream.
6.6 Subtitles
Resolve exactly one mode:
off: no selected track;render: return the source artifact when Media3 can render it with required fidelity;convert: return a v3 artifact URL, MIME, format and timing origin;burn_in: force video encoding and declare any HDR/range degradation; or- terminal when the selected subtitle cannot meet policy.
Map preserve to require_authored_fidelity and compatible to
allow_simplified_rendering, subject to administrator policy. ASS/SSA styling,
font attachments, PGS, VobSub and DVB must each have fixtures; do not infer all
bitmap paths from one codec test.
Converted artifacts need session ownership, signed or authenticated fetches, bounded cache lifetime, and timing relative to the returned stream origin.
7. Transport orchestration refactor
Extract the body of HandleStartTranscode into a server-callable operation that
accepts a validated normalized recipe and returns:
- final manifest URL;
- effective local/remote node and hardware encoder;
- effective file ID;
- final codecs/resolution/bitrate/channels/range;
- player/source timeline mapping;
- seek-window semantics; and
- typed terminal/startup failure.
Both the legacy endpoint and v3 service call this operation. The legacy handler
continues accepting its existing body and maps it to the normalized recipe;
there is no /api/v1 breaking change.
Jellyfin compatibility is behaviorally out of scope for protocol v3. Its
handlers currently start local/remote transcodes through parallel paths and a
shared TranscodeManager, not through HandleStartTranscode. Preserve those
wire and lifecycle paths during this work and keep their regression tests
green. The extracted lower-level starter may be adopted by jellycompat only in
a separately reviewed convergence change; do not half-migrate one of its local
or remote branches.
Apply the same extraction to progressive remux startup where necessary. The v3 planner chooses a recipe; the transport starter is not allowed to silently change it. If safety logic must change copy to encode—for example seeked HEVC or subtitle burn-in—the starter returns the effective normalized recipe so the plan, claims, warnings, recipe card, and FFmpeg process remain identical.
8. Route events and observability
Add playback_route_events with bounded columns for:
- playback/session/plan/plan-attempt identities;
- event and failure classification;
- fallback reason;
- output-route generation;
- sanitized diagnostics JSON;
- user/profile/client metadata derived from auth and headers; and
- received timestamp.
Accept only a closed event set initially:
plan_selected
plan_invalidated
plan_failed
first_frame
terminal
stopped
Allowlist diagnostic keys and cap key count, key/value length, body size, and events per user/attempt. Strip URLs, headers, tokens, file paths, free-form stack traces, and unknown keys. Enqueue/batch writes so telemetry never blocks playback planning.
Add exact release queries for:
- starts by delivery, recipe, dynamic range and client model;
- first-frame rate and latency;
- replan/failure rate by classification;
- terminal reasons;
- PCM retry and passthrough outcomes;
- repeated-key/loop prevention; and
- DV/HDR degradation transformations.
Operational logs include IDs and reason codes but no stream tokens.
9. Implementation sequence
Each phase should be a separate reviewable PR and link the playback capability epic/sub-issue.
Phase 0 — Freeze the contract
- Add Go structs/enums mirroring the Kotlin v3 wire model.
- Split only the start-envelope dispatch: protocol
3returns the disabled no-session negotiation response, while absent/non-3 requests replay the buffered body through the unchanged legacy decoder. - Resolve the additive capability, effective-file, subtitle-policy and route- diagnostic gaps from section 3.3 in a coordinated Android change.
- Add request/response JSON golden fixtures and canonical attempt-key fixtures.
- Add strict validation tests, legacy-envelope tests, and unknown-field tests.
- Add
GET /playback/capabilityand the disabled negotiation response. - Keep
playback.protocol_v3_enabled=false.
Exit: Go and Android decode the same fixtures and produce identical attempt keys; Android populates the detailed capability fields; legacy start tests are unchanged; disabled v3 starts allocate no legacy playback session.
Phase 1 — Source facts and transformation registry
- Complete normalized source descriptors from
MediaFiletracks. - Add MEL/FEL/unknown metadata and conservative probe behavior.
- Add installed FFmpeg/dovi tool capability probes.
- Define transformation registry and terminal reasons.
- Add scanner/model migration or JSONB backfill handling as required.
Exit: every source fact used by the planner is present or explicitly unknown; no unavailable transformation is advertised.
Phase 2 — Pure planner
- Implement capability normalization, stable tracks, quality policy, direct eligibility, candidate ordering, recipe claims, deterministic plan IDs and attempt-key rejection.
- Cover direct, progressive remux, HLS copy, audio adaptation, full transcode, subtitles and terminal outcomes with table-driven tests.
- Do not start sessions or FFmpeg from planner tests.
Exit: the full Phase 0 decision matrix passes as a pure deterministic function.
Phase 3 — Start orchestration
- Wire the Phase 0 dispatcher's protocol-3 branch to the completed v3 orchestration service; keep the legacy branch unchanged.
- Extract reusable transcode/remux transport starters.
- Start sessions and final transports from the v3 service.
- Return complete plans with final URLs and timelines.
- Persist attempt/current-plan state.
Exit: every playable fixture fetches and prepares its returned URL; a terminal fixture allocates no leaked session/transcode.
Phase 4 — Replan and idempotency
- Add route, handler, persistent idempotency records and per-session serialization.
- Replan track/quality/output changes and classified runtime failures.
- Reuse session IDs when safe; close superseded transports exactly once.
- Reject attempted recipes and stale failed plan IDs.
- Add restart, duplicate request, concurrent request and crash-window tests.
Exit: repeated identical replan requests return the same result without a second FFmpeg process; a different payload under the same ID conflicts.
Phase 5 — Route events and rollout controls
- Add sanitized asynchronous route-event ingestion and retention cleanup.
- Add release queries/dashboards and structured plan logs.
- Add shadow planning for legacy starts on validation servers: compute and log v3 choice without advertising or executing it.
- Compare v3 shadow decisions with current production routes.
Exit: telemetry is queryable, bounded, privacy-reviewed and cannot delay a start/replan response.
Phase 6 — Hardware and multi-repository validation
- Run original/remux/transcode fixtures locally and through proxy/transcode nodes.
- Verify restart reconstruction, session limits, transcode-disabled policy, alternate versions and output-route changes.
- Run Android Shield tests first on SDR/1080p, then the named 4K DV/AVR chain.
- Record plan, route events, decoder, TV range indicator and AVR format for the same attempt.
- Publish minimum server revision and enable v3 on validation deployments.
Exit: all Phase 0 fixtures pass on the deployed revision and Android no
longer reports server_upgrade_required.
10. Test matrix
At minimum, include:
Contract and compatibility
- absent/2/3/unknown protocol versions;
- disabled and enabled feature negotiation;
- v3 nested capabilities are consumed, not silently dropped;
- legacy start response and HTTP status remain unchanged;
- old web/Apple transcode-start requests still work;
- malformed plan/terminal responses cannot be emitted.
Direct and adaptation
- H.264/AAC MP4 direct;
- HEVC MKV original when the exact Media3 envelope supports it;
- container-only progressive and HLS remux;
- unsupported TrueHD/DTS layout with video copy and audio conversion;
- resolution/user-cap quality transcode;
- transcode disabled, user denied, capacity unavailable, missing tool and policy denied terminal results;
- alternate lower-resolution file with stable track remapping.
HDR/DV
- HDR10, HDR10+, HLG and SDR direct gates;
- DV profiles 5, 7 MEL, 7 FEL, 7 unknown and relevant profile 8 variants;
- BL compatibility ID and range mismatch;
- native DV, validated HDR10 base-layer fallback and SDR tone map;
- missing
dovi_rpu/dovi_split/dovi-tool behavior; - no transformation claims when output was not validated.
Audio and subtitles
- local decode versus passthrough by exact channels/layout;
- E-AC-3 JOC, TrueHD, DTS-HD/core and PCM fallback replan;
- text sidecar/embedded, ASS with/without authored fidelity, PGS, VobSub, DVB, conversion and burn-in;
- artifact timing after seeked remux/transcode;
- burn-in forces encode and reports HDR degradation.
Recovery and load
- transport/decoder failures, track/quality/output invalidation;
- deterministic same-recipe loop rejection;
- idempotent duplicate and concurrent replans;
- API and transcode-node restart between plan and first manifest;
- session expiry, explicit stop and terminal cleanup;
- no double capacity accounting or orphan FFmpeg process;
- route-event flood/body/diagnostic limits.
11. Verification commands
Commands assume the repository root is the cwd.
GOWORK=off go test ./internal/playback ./internal/api/handlers ./internal/transcodenode ./internal/nodepool
GOWORK=off go test -race ./internal/playback ./internal/api/handlers
make verify-local-paths
make lint
make build
Add a focused integration target that starts PostgreSQL/Redis, creates a media fixture, calls v3 start/replan, fetches the returned stream or manifest, submits route events, and verifies cleanup. Contract fixtures must also run in Android CI against its Kotlin serializers and attempt-key function. Android tests also prove detailed codec/layout capability emission and a local PCM-recovery route event.
12. Rollout and rollback
- Merge schema/types with v3 disabled.
- Deploy shadow planner and compare decisions without changing playback.
- Enable v3 only on a validation server with the Android test build.
- Complete SDR, HDR, DV, passthrough, subtitle, restart and node fixtures.
- Publish the minimum server revision.
- Enable v3 for production servers after the named soak and telemetry gates.
Rollback is the dynamic playback.protocol_v3_enabled setting. Disabling it
stops advertising the feature and returns the no-session negotiation response
to new v3 starts. Existing v3 sessions remain playable until stop/expiry; do
not terminate active streams during rollback. Legacy web and Apple endpoints
remain available throughout.
13. Definition of done
- The three Android canonical endpoints and playback capability endpoint are implemented with profile/session ownership checks.
- Every playable response is complete, final and fetchable without client-owned recipe decisions.
- Every enabled delivery/transformation has a passing integration fixture.
- Direct decisions validate the selected track and full source/output facts.
- Android supplies the detailed per-codec and per-layout capability facts those direct decisions consume; missing facts fail conservatively.
- Terminal reasons replace unsafe direct fallbacks.
- Replans are idempotent, loop-safe, restart-aware and capacity-safe.
- Route telemetry is bounded, sanitized and queryable.
- Existing legacy server, web, Apple, Jellyfin compatibility and audiobook playback tests remain green.
- Android passes direct/remux/transcode/replan tests against the deployed server revision, followed by the documented 4K Dolby Vision/AVR validation.