feat(playback): add protocol v3 planning and recovery (#398)

* 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>
This commit is contained in:
Quick
2026-07-14 11:51:27 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 28c6ddc237
commit 854d07cf8f
62 changed files with 11664 additions and 496 deletions
@@ -0,0 +1,822 @@
# 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_unavailable` outcome.
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.md`
- `silo-android/docs/playback/02-migration-compatibility-validation.md`
- `silo-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:
1. Decode only an envelope containing `protocol_version`.
2. When it equals `3`, decode strictly as `PlaybackStartRequestV3` and call the
v3 orchestration service.
3. 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:
```text
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:
```json
{
"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_id` is 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_id` and deterministic `plan_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:
```text
file:{media_file_id}:audio:{ffmpeg_audio_ordinal}
file:{media_file_id}:subtitle:{combined_subtitle_ordinal}
```
Freeze the combined subtitle ordering used by `buildSubtitleURLs`:
1. external subtitles in stored order, starting at zero;
2. embedded subtitles at `len(external) + embedded_ordinal`; skipped legacy
bitmap entries retain their ordinal hole; and
3. downloaded subtitles in repository `created_at` order at
`len(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:
```text
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:
1. validate policy, source, candidate and tooling without touching the active
route;
2. reserve the active session's existing stream/transcode capacity slot so the
replacement is neither double-counted nor denied by its own predecessor;
3. start the successor in a plan-scoped staging directory/transport generation,
or under an explicitly replacement-linked new session ID;
4. wait until the successor URL is fetchable or its startup state is validated;
5. atomically commit effective file, tracks, recipe, plan ID, timeline and
signed route;
6. close and reap the predecessor; and
7. 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:
```text
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:
1. `original_http`;
2. `server_remux_progressive` when the container alone is incompatible and
progressive seek semantics are acceptable;
3. `server_remux_hls` when HLS packaging is required;
4. `server_remux_hls` with copied video plus audio adaptation, with the audio
change named in the effective recipe, transformations, claims and warnings;
5. `server_transcode_hls`; and
6. 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:
- `original` to no user-requested reduction, while still permitting mandatory
compatibility adaptation;
- a fixed rung to a maximum height with no upscaling; and
- `auto` to 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=true` claim;
- 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:
```text
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_hdr10` using a compatible HDR10 base layer;
- `dv_p7_to_p8_1` only when the selected toolchain and fixture prove the output;
- `hdr_to_sdr_tonemap` only 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](https://ffmpeg.org/ffmpeg-bitstream-filters.html#dovi_005frpu)).
[`dovi_tool`](https://github.com/quietvoid/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:
```text
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 `3` returns 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/capability` and 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 `MediaFile` tracks.
- 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.
```text
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
1. Merge schema/types with v3 disabled.
2. Deploy shadow planner and compare decisions without changing playback.
3. Enable v3 only on a validation server with the Android test build.
4. Complete SDR, HDR, DV, passthrough, subtitle, restart and node fixtures.
5. Publish the minimum server revision.
6. 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.
+118 -51
View File
@@ -176,6 +176,23 @@ type PlaybackHandler struct {
// restart reconstruct) shared with the jellycompat handler. The handler
// delegates all transcode-session and recipe operations to it.
tm *playback.TranscodeManager
// PlanStoreV3 owns the short-lived protocol-v3 control-plane state. Router
// wiring replaces the in-memory default with PostgreSQL in integrated mode.
PlanStoreV3 playback.PlanStoreV3
v3RegistryOnce sync.Once
v3Registry *playback.TransformationRegistryV3
v3NodeCapabilitiesMu sync.Mutex
v3NodeCapabilities map[string]v3NodeCapabilityCache
v3EventOnce sync.Once
v3EventQueue chan playback.RouteEventRecordV3
v3ReplanMu sync.Mutex
v3ReplanLocks map[string]*v3ReplanLock
v3ReplanSlotsOnce sync.Once
v3ReplanSlots chan struct{}
v3EventRateMu sync.Mutex
v3EventRates map[string]v3EventRate
v3FlagMu sync.Mutex
v3Flags map[string]v3FlagCacheEntry
}
type PlaybackWatchScrobbler interface {
@@ -196,6 +213,7 @@ func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathReso
sessionMgr: sessionMgr,
realtimeCommands: make(map[string]playbackCommandRecord),
tm: playback.NewTranscodeManager(),
PlanStoreV3: playback.NewMemoryPlanStoreV3(),
}
if len(opts) > 0 {
h.fileResolver = opts[0]
@@ -649,7 +667,7 @@ func (h *PlaybackHandler) playbackStreamURL(s *playback.Session) string {
func identityRecipeCard(s *playback.Session) playback.RecipeCard {
switch s.PlayMethod {
case playback.PlayRemux:
return playback.NewRemuxRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID, s.TranscodeAudio, s.AudioTrackIndex)
return playback.NewRemuxRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID, s.TranscodeAudio, s.AudioTrackIndex, s.RemuxDVMode)
default:
return playback.NewDirectRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID)
}
@@ -713,6 +731,29 @@ func requestedMediaFileID(session *playback.Session) int {
return session.MediaFileID
}
func remoteTransportID(session *playback.Session) string {
if session != nil && session.TranscodeTransportID != "" {
return session.TranscodeTransportID
}
if session == nil {
return ""
}
return session.ID
}
func (h *PlaybackHandler) closeTranscodeForSession(session *playback.Session) {
if session == nil {
return
}
// Local sessions remain keyed by the public playback session. Remote v3
// processes use a plan-scoped transport identity so a prepared successor can
// coexist with its predecessor until commit.
h.tm.CloseTranscodeSession(session.ID, "")
if session.TranscodeNodeURL != "" {
h.tm.StopRemoteTranscode(remoteTransportID(session), session.TranscodeNodeURL)
}
}
func (h *PlaybackHandler) loadFileByPreferredID(
ctx context.Context,
preferredID int,
@@ -1226,7 +1267,7 @@ func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *play
}
}
h.tm.CloseTranscodeSession(session.ID, session.TranscodeNodeURL)
h.closeTranscodeForSession(session)
if syncNow {
h.syncSessionsNow(ctx, syncReason)
}
@@ -1260,7 +1301,7 @@ func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *pla
// Abort is a connection drop / non-terminal teardown — keep the recipe card
// so the client can reconstruct on reconnect.
h.tm.CloseTranscodeSession(session.ID, session.TranscodeNodeURL)
h.closeTranscodeForSession(session)
if syncNow {
h.syncSessionsNow(ctx, syncReason)
}
@@ -1358,8 +1399,36 @@ func (h *PlaybackHandler) persistAudioPreference(
// --- Handler methods ---
// HandleStartPlayback handles POST /playback/start.
// HandleStartPlayback dispatches the shared start endpoint by protocol
// envelope while preserving the exact legacy request decoder and behavior.
func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
if apimw.GetUserID(r.Context()) == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPlaybackV3BodyBytes))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var envelope struct {
ProtocolVersion *int `json:"protocol_version"`
}
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&envelope); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if envelope.ProtocolVersion != nil && *envelope.ProtocolVersion == playback.ProtocolV3 {
h.handleStartPlaybackV3(w, r, body)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
h.handleStartPlaybackLegacy(w, r)
}
// handleStartPlaybackLegacy is the pre-v3 start implementation. Keep changes
// to this function independent from protocol-v3 routing.
func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -1703,6 +1772,11 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
}
}
if h.protocolV3ShadowEnabled(r.Context()) {
shadowReq := req
shadowReq.ProfileID = profileID
go h.shadowLegacyPlaybackV3(context.WithoutCancel(r.Context()), shadowReq, requestedFile, effectiveFile, audioTrackIndex, session.PlayMethod, session.TranscodeAudio, session.ID)
}
h.syncSessionsNow(r.Context(), "start")
writeJSON(w, http.StatusCreated, resp)
}
@@ -2163,8 +2237,12 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
// changing only the audio track. SourceVideoCodec/TotalDuration
// come from the file; the resolution/codec/bitrate targets and
// hwaccel come from the session's persisted stream state.
// A v3 session's node job runs under its generation-scoped
// transport ID; restarting under the bare session ID would
// spawn a duplicate job beside it.
restartTransportID := remoteTransportID(&updatedSession)
nodeReq := transcodenode.TranscodeStartRequest{
SessionID: sessionID,
SessionID: restartTransportID,
InputPath: file.FilePath,
SourceVideoCodec: file.CodecVideo,
SeekSeconds: seekSeconds,
@@ -2184,6 +2262,12 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
if strings.TrimSpace(nodeReq.HWAccel) == "" {
nodeReq.HWAccel = h.playbackConfig().HWAccel
}
// A v3 DV strip remux carries its bitstream filter in the
// durable session route; dropping it here would hand the node
// a DV7 copy recipe that leaves dangling RPUs.
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") {
nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
}
body, _ := json.Marshal(nodeReq)
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
@@ -2220,22 +2304,24 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
}
card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, nodeURL, playback.TranscodeOpts{
InputPath: nodeReq.InputPath,
SessionID: nodeReq.SessionID,
SourceVideoCodec: nodeReq.SourceVideoCodec,
SeekSeconds: nodeReq.SeekSeconds,
StartSegmentNumber: nodeReq.StartSegmentNumber,
TargetResolution: nodeReq.TargetResolution,
TargetCodecVideo: nodeReq.TargetCodecVideo,
TargetCodecAudio: nodeReq.TargetCodecAudio,
TargetBitrateKbps: nodeReq.TargetBitrateKbps,
SegmentDuration: nodeReq.SegmentDuration,
HWAccel: effectiveHWAccel,
AudioTrackIndex: nodeReq.AudioTrackIndex,
SubtitleTrackIndex: nodeReq.SubtitleTrackIndex,
SubtitleBurnIn: nodeReq.SubtitleBurnIn,
SubtitleCodec: nodeReq.SubtitleCodec,
TotalDuration: nodeReq.TotalDuration,
InputPath: nodeReq.InputPath,
SessionID: sessionID,
TranscodeTransportID: restartTransportID,
VideoBitstreamFilter: nodeReq.VideoBitstreamFilter,
SourceVideoCodec: nodeReq.SourceVideoCodec,
SeekSeconds: nodeReq.SeekSeconds,
StartSegmentNumber: nodeReq.StartSegmentNumber,
TargetResolution: nodeReq.TargetResolution,
TargetCodecVideo: nodeReq.TargetCodecVideo,
TargetCodecAudio: nodeReq.TargetCodecAudio,
TargetBitrateKbps: nodeReq.TargetBitrateKbps,
SegmentDuration: nodeReq.SegmentDuration,
HWAccel: effectiveHWAccel,
AudioTrackIndex: nodeReq.AudioTrackIndex,
SubtitleTrackIndex: nodeReq.SubtitleTrackIndex,
SubtitleBurnIn: nodeReq.SubtitleBurnIn,
SubtitleCodec: nodeReq.SubtitleCodec,
TotalDuration: nodeReq.TotalDuration,
})
resp.StreamURL = h.buildProxyManifestURL(card, proxyNode)
} else {
@@ -2254,6 +2340,10 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
UserID: updatedSession.UserID,
ProfileID: updatedSession.ProfileID,
MediaFileID: updatedSession.MediaFileID,
// v3 sessions route by transport ID and pin an explicit DV
// mode; a re-minted token must not silently shed either.
TranscodeTransportID: updatedSession.TranscodeTransportID,
RemuxDVMode: string(updatedSession.RemuxDVMode),
}
if plan.TranscodeNode != nil {
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
@@ -2522,7 +2612,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
if h.tm.GetTranscodeSession(req.SessionID) != nil || session.TranscodeNodeURL != "" {
// Restarting the transcode under the SAME session id (quality/seek
// change) — keep the card; it is re-saved with the new opts below.
h.tm.CloseTranscodeSession(req.SessionID, session.TranscodeNodeURL)
h.closeTranscodeForSession(session)
}
abortCurrentSession := func(reason string, cause error) {
if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) {
@@ -2723,40 +2813,17 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TotalDuration: float64(file.Duration),
}
body, _ := json.Marshal(nodeReq)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tcNode.URL+"/transcode/start", bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build transcode request")
return
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+h.JWTSecret)
resp, err := http.DefaultClient.Do(httpReq)
nodeResp, status, err := h.startRemotePlaybackTransport(context.Background(), tcNode.URL, nodeReq)
if err != nil {
slog.ErrorContext(r.Context(), "remote transcode start failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID)
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", resp.StatusCode, "node", tcNode.URL)
if status != http.StatusAccepted {
slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", status, "node", tcNode.URL)
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
return
}
var nodeResp transcodenode.TranscodeStartResponse
if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil {
slog.WarnContext(r.Context(), "remote transcode start response decode failed", "component", "api",
"error", err,
"node", tcNode.URL,
"session", req.SessionID,
"playback_session_id", req.SessionID,
)
}
effectiveHWAccel := strings.TrimSpace(nodeResp.HWAccel)
if effectiveHWAccel == "" {
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
@@ -2819,7 +2886,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
// fresh ffmpeg is the sole writer.
unlock := h.tm.LockSessionLifecycle(req.SessionID)
h.tm.CloseTranscodeSession(req.SessionID, "")
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
transcodeSession, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{
InputPath: file.FilePath,
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
SessionID: req.SessionID,
@@ -2914,7 +2981,7 @@ func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *h
if session.TranscodeNodeURL != "" {
h.touchSessionActivity(sessionID)
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
"/transcode/"+sessionID+"/master.m3u8")
"/transcode/"+remoteTransportID(session)+"/master.m3u8")
return
}
// Local transcode whose process state was lost: reconstruct it from the
@@ -2969,7 +3036,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
h.touchSessionActivity(sessionID)
segmentName := chi.URLParam(r, "name")
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
"/transcode/"+sessionID+"/segment/"+segmentName)
"/transcode/"+remoteTransportID(session)+"/segment/"+segmentName)
return
}
// Resume near the segment the client is fetching so reconstruct does not
@@ -0,0 +1,84 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
// startLocalPlaybackTransport is the shared local ffmpeg launch primitive for
// legacy and protocol-v3 orchestration. Callers retain ownership of lifecycle
// locking and decide whether registration is immediate or transactionally
// staged.
func (h *PlaybackHandler) startLocalPlaybackTransport(ctx context.Context, opts playback.TranscodeOpts) (*playback.TranscodeSession, error) {
return playback.StartTranscode(context.WithoutCancel(ctx), opts)
}
// startRemotePlaybackTransport is the shared remote-node launch primitive.
// It returns the node's HTTP status separately so legacy and v3 can preserve
// their existing public error envelopes while executing identical transport
// startup and response parsing.
func (h *PlaybackHandler) startRemotePlaybackTransport(ctx context.Context, nodeURL string, request transcodenode.TranscodeStartRequest) (transcodenode.TranscodeStartResponse, int, error) {
body, err := json.Marshal(request)
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(requestCtx, http.MethodPost, nodeURL+"/transcode/start", bytes.NewReader(body))
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Authorization", "Bearer "+h.JWTSecret)
response, err := http.DefaultClient.Do(httpRequest)
if err != nil {
return transcodenode.TranscodeStartResponse{}, 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
// Drain the (small) error body so the transport can reuse the
// connection instead of tearing it down on every failed start.
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return transcodenode.TranscodeStartResponse{}, response.StatusCode, nil
}
var result transcodenode.TranscodeStartResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
slog.WarnContext(ctx, "remote transcode start response decode failed", "component", "api", "node", nodeURL, "error", err)
}
return result, response.StatusCode, nil
}
func fetchRemoteTranscodeCapabilities(ctx context.Context, nodeURL, jwtSecret string) (playback.HWAccelInfo, error) {
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, strings.TrimRight(nodeURL, "/")+"/hw-capabilities", nil)
if err != nil {
return playback.HWAccelInfo{}, err
}
request.Header.Set("Authorization", "Bearer "+jwtSecret)
response, err := http.DefaultClient.Do(request)
if err != nil {
return playback.HWAccelInfo{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return playback.HWAccelInfo{}, fmt.Errorf("node returned %d", response.StatusCode)
}
var info playback.HWAccelInfo
if err := json.NewDecoder(response.Body).Decode(&info); err != nil {
return playback.HWAccelInfo{}, err
}
info.Source = "transcode_node"
info.NodeURL = nodeURL
return info, nil
}
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
package handlers
import (
"context"
"log/slog"
"runtime/debug"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
const playbackV3ShadowSetting = "playback.protocol_v3_shadow_enabled"
func (h *PlaybackHandler) protocolV3ShadowEnabled(ctx context.Context) bool {
return h.settingFlagCachedV3(ctx, playbackV3ShadowSetting)
}
// shadowLegacyPlaybackV3 compares the production legacy route with a v3 plan
// without advertising or executing that plan. Legacy capabilities lack the
// detailed v3 fields, so exact source facts are used only as an explicitly
// labelled validation inference; this is never a production compatibility
// claim and cannot enable v3 playback.
func (h *PlaybackHandler) shadowLegacyPlaybackV3(ctx context.Context, req startPlaybackRequest, requestedFile, effectiveFile *models.MediaFile, audioIndex int, productionMethod playback.PlayMethod, productionTranscodeAudio bool, sessionID string) {
// The shadow planner runs on a bare goroutine off the production start
// path; an escaped panic here would take down the whole process for a
// telemetry-only comparison, so it must fail closed instead.
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "playback v3 shadow planner panicked",
"component", "playback", "playback_session_id", sessionID,
"panic", recovered, "stack", string(debug.Stack()))
}
}()
if !h.protocolV3ShadowEnabled(ctx) || requestedFile == nil || effectiveFile == nil {
return
}
request := legacyShadowRequestV3(req, effectiveFile, audioIndex, sessionID)
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: request,
RequestedFile: requestedFile,
EffectiveFile: effectiveFile,
AudioTrackIndex: audioIndex,
Settings: h.plannerSettingsV3(ctx),
Registry: h.transformationRegistryV3(ctx),
Now: time.Now(),
})
attrs := []any{
"component", "playback",
"playback_session_id", sessionID,
"requested_file_id", requestedFile.ID,
"effective_file_id", effectiveFile.ID,
"production_method", productionMethod,
"production_transcode_audio", productionTranscodeAudio,
"capability_basis", "legacy_source_exact_inference",
}
if result.Plan != nil {
attrs = append(attrs,
"shadow_plan_id", result.Plan.PlanID,
"shadow_delivery", result.Plan.Delivery,
"shadow_method", result.PlayMethod,
"shadow_transcode_audio", result.TranscodeAudio,
"shadow_reason", result.Plan.DecisionReason,
"route_match", result.PlayMethod == productionMethod && result.TranscodeAudio == productionTranscodeAudio,
)
} else if result.Terminal != nil {
attrs = append(attrs, "shadow_terminal", result.Terminal.Reason, "route_match", false)
}
slog.InfoContext(ctx, "playback v3 shadow decision", attrs...)
}
func legacyShadowRequestV3(req startPlaybackRequest, file *models.MediaFile, audioIndex int, sessionID string) playback.StartRequestV3 {
source := playback.SourceDescriptorFromFileV3(file, audioIndex)
hdr := legacyShadowHDRV3(req)
features := []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}
capabilities := playback.ClientCodecCapabilitiesV3{
CodecsVideo: append([]string(nil), req.CodecsVideo...),
CodecsAudio: append([]string(nil), req.CodecsAudio...),
Containers: append([]string(nil), req.Containers...),
MaxResolution: req.MaxResolution,
HDR: req.HDR,
HDRDetails: hdr,
}
if containsStringFoldV3(req.CodecsVideo, source.VideoCodec) {
capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
Codec: source.VideoCodec, Profiles: []string{source.VideoProfile}, Levels: []int{source.VideoLevel}, BitDepths: []int{source.BitDepth},
MaxWidth: source.Width, MaxHeight: source.Height, MaxFrameRate: source.FrameRate, MaxBitrateKbps: source.BitrateKbps, Hardware: true,
}}
}
var passthrough *playback.AudioPassthroughV3
if req.AudioPassthrough != nil {
passthrough = &playback.AudioPassthroughV3{PassthroughCodecs: append([]string(nil), req.AudioPassthrough.PassthroughCodecs...), SpatializerEnabled: req.AudioPassthrough.SpatializerEnabled, MaxChannels: req.AudioPassthrough.MaxChannels}
if containsStringFoldV3(req.AudioPassthrough.PassthroughCodecs, source.AudioCodec) && source.AudioChannels > 0 && source.AudioLayout != "" && source.AudioChannels <= req.AudioPassthrough.MaxChannels {
passthrough.Entries = []playback.AudioPassthroughEntryV3{{Codec: source.AudioCodec, ChannelCounts: []int{source.AudioChannels}, Layouts: []string{source.AudioLayout}}}
features = append(features, playback.FeatureLayoutPassthrough)
}
capabilities.AudioPassthrough = passthrough
}
engines := map[string]playback.EngineCapabilityV3{
string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true, EmbeddedBitmap: true, SidecarBitmap: true}},
string(playback.EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
string(playback.EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
}
return playback.StartRequestV3{
ProtocolVersion: playback.ProtocolV3, ClientFeatures: features, FileID: file.ID, ProfileID: req.ProfileID,
PlaybackAttemptID: "shadow-" + sessionID, QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
StartPosition: req.StartPosition, AudioTrackIndex: &audioIndex, Capabilities: capabilities,
ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: features, Platform: "legacy-shadow", Output: playback.OutputContextV3{HDRDetails: hdr, AudioPassthrough: passthrough}, Engines: engines},
}
}
func legacyShadowHDRV3(req startPlaybackRequest) *playback.HDRCapabilitiesV3 {
if req.HdrDetails != nil {
return &playback.HDRCapabilitiesV3{HDR10: req.HdrDetails.HDR10, HDR10Plus: req.HdrDetails.HDR10Plus, HLG: req.HdrDetails.HLG, DolbyVisionProfiles: append([]int(nil), req.HdrDetails.DolbyVisionProfiles...)}
}
if req.HDR {
return &playback.HDRCapabilitiesV3{HDR10: true}
}
return nil
}
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -159,7 +159,7 @@ func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) {
seekSeconds = s
}
}
if err := playback.ServeRemux(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile()); err != nil {
if err := playback.ServeRemuxWithDVMode(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), session.RemuxDVMode, h.ffmpegPath()); err != nil {
h.handleTransportStartFailure(r.Context(), session, file, err)
}
@@ -191,7 +191,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
setPlaybackSessionLogContext(r, sessionID)
trackParam := chi.URLParam(r, "track")
trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam)
trackIndex, requestedFormat, err := playback.ParseSubtitleTrackParam(trackParam)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle track index")
return
@@ -224,7 +224,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
sub := file.ExternalSubtitles[trackIndex]
// Serve ASS/SSA external subtitles as raw data for client-side rendering.
if playback.IsASS(sub.Format) {
if playback.IsASS(sub.Format) && requestedFormat != "vtt" {
data, err := playback.LoadExternalSubtitleRaw(sub.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error",
@@ -264,7 +264,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// demuxed, so the first byte lands within ~1s even on network
// storage. Works identically for direct-play, remux, and
// transcode because it doesn't depend on any other ffmpeg.
h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session)
h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session, requestedFormat)
return
}
@@ -295,7 +295,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(dl.Format)) {
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
return
}
@@ -307,7 +307,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
}
// Convert to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTT(data, string(dl.Format))
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
@@ -488,7 +488,7 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
// track, seeked to the best-known playback position, and pipes its
// stdout directly to w. Because this ffmpeg is independent of the video
// pipeline, it works the same for direct play, remux, and transcode.
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session, requestedFormat ...string) {
track := file.SubtitleTracks[embeddedIndex]
outFormat := "vtt"
switch {
@@ -537,6 +537,20 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
AllowWindow: allowWindow,
FFmpegPath: h.ffmpegPath(),
}
if len(requestedFormat) > 0 && requestedFormat[0] == "vtt" {
// Only text sources can be converted to WebVTT. A bitmap track (PGS
// reaches here because it is deliverable as .sup; DVD/DVB are rejected
// upstream) carries no text, so honoring the override would spawn an
// ffmpeg that always fails after the 200 and headers are committed.
// Reject before any spawn or header write.
if playback.NeedsBurnIn(track.Codec) {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
"Bitmap subtitle tracks cannot be converted to WebVTT")
return
}
opts.TargetFormat = "vtt"
outFormat = "vtt"
}
w.Header().Set("Access-Control-Allow-Origin", "*")
+45
View File
@@ -196,6 +196,51 @@ func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
}
}
// A .vtt request for a bitmap (PGS) embedded track must be rejected up front:
// PGS passes the burn-in guard because it is deliverable as .sup, but it has
// no text to convert, so forcing WebVTT would spawn an ffmpeg that always
// fails after the 200 and headers are committed.
func TestHandleSubtitle_BitmapTrackVTTRequestReturns415(t *testing.T) {
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Duration: 3600,
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "eng", Codec: "hdmv_pgs_subtitle"},
},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.vtt")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, body = %s; want 415", rr.Code, rr.Body.String())
}
var body struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode error body: %v (body = %s)", err, rr.Body.String())
}
if body.Error != "unsupported_media_type" {
t.Fatalf("error code = %q, want %q", body.Error, "unsupported_media_type")
}
}
func TestSubtitleSourceFileIDPinsURLAcrossEffectiveFileSwitch(t *testing.T) {
session := &playback.Session{MediaFileID: 200, RequestedMediaFileID: 100}
+1 -27
View File
@@ -2,11 +2,8 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/Silo-Server/silo-server/internal/buildinfo"
"github.com/Silo-Server/silo-server/internal/nodepool"
@@ -57,28 +54,5 @@ func (h *SystemHandler) HandleBuildInfo(w http.ResponseWriter, _ *http.Request)
}
func (h *SystemHandler) fetchRemoteHWAccel(ctx context.Context, node *nodepool.Node) (playback.HWAccelInfo, error) {
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, node.URL+"/hw-capabilities", nil)
if err != nil {
return playback.HWAccelInfo{}, err
}
req.Header.Set("Authorization", "Bearer "+h.jwtSecret)
resp, err := client.Do(req)
if err != nil {
return playback.HWAccelInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return playback.HWAccelInfo{}, fmt.Errorf("node returned %d", resp.StatusCode)
}
var info playback.HWAccelInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return playback.HWAccelInfo{}, err
}
info.Source = "transcode_node"
info.NodeURL = node.URL
return info, nil
return fetchRemoteTranscodeCapabilities(ctx, node.URL, h.jwtSecret)
}
+10
View File
@@ -50,6 +50,7 @@ import (
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/playback/planstore"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/policy"
"github.com/Silo-Server/silo-server/internal/ratelimit"
@@ -805,6 +806,12 @@ func NewRouter(deps Dependencies) chi.Router {
} else {
playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr)
}
if deps.DB != nil {
playbackHandler.PlanStoreV3 = planstore.NewPostgres(deps.DB)
}
// Maintenance also bounds the in-memory fallback store: without it a
// DB-less deployment accumulates attempts and replans forever.
playbackHandler.StartV3Maintenance(deps.AppContext)
// Wire UserStoreProvider for progress/history persistence.
if deps.UserStoreProvider != nil {
@@ -2279,6 +2286,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.FFmpegLogSink = deps.FFmpegLogSink
r.Route("/playback", func(r chi.Router) {
r.Get("/capability", playbackHandler.HandlePlaybackCapabilityV3)
// HLS transcode delivery — no profile auth needed;
// session ID (UUID) serves as the access token, same
// pattern as /stream/{session_id}.
@@ -2292,6 +2300,8 @@ func NewRouter(deps Dependencies) chi.Router {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/start", playbackHandler.HandleStartPlayback)
r.Post("/{session_id}/replan", playbackHandler.HandleReplanPlaybackV3)
r.Post("/route-events", playbackHandler.HandlePlaybackRouteEventV3)
r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress)
r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack)
r.Delete("/{session_id}", playbackHandler.HandleStopPlayback)
+70 -23
View File
@@ -153,31 +153,78 @@ func (f *MediaFile) PrimaryDVProfile() int {
return f.VideoTracks[0].DVProfile
}
// NormalizeVideoBitDepth returns an explicit probe value when available and
// otherwise derives the bit depth from stable ffprobe fields. FFprobe commonly
// omits bits_per_raw_sample for HEVC even though the pixel format and profile
// are conclusive (for example yuv420p10le / Main 10).
func NormalizeVideoBitDepth(explicit int, pixelFormat, profile string) int {
if explicit > 0 {
return explicit
}
pixelFormat = strings.ToLower(strings.TrimSpace(pixelFormat))
for _, candidate := range []struct {
markers []string
depth int
}{
{markers: []string{"p016", "p16", "gray16", "16le", "16be"}, depth: 16},
{markers: []string{"p014", "p14", "gray14", "14le", "14be"}, depth: 14},
{markers: []string{"p012", "p12", "gray12", "12le", "12be"}, depth: 12},
{markers: []string{"p010", "p10", "gray10", "10le", "10be"}, depth: 10},
{markers: []string{"p009", "p9", "gray9", "9le", "9be"}, depth: 9},
} {
for _, marker := range candidate.markers {
if strings.Contains(pixelFormat, marker) {
return candidate.depth
}
}
}
profile = strings.ToLower(strings.TrimSpace(profile))
for _, marker := range []string{"main 12", "main12", "12-bit", "12 bit"} {
if strings.Contains(profile, marker) {
return 12
}
}
for _, marker := range []string{"main 10", "main10", "10-bit", "10 bit"} {
if strings.Contains(profile, marker) {
return 10
}
}
switch pixelFormat {
case "yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p", "nv12", "nv21", "rgb24", "bgr24":
return 8
}
return 0
}
// VideoTrack represents a probed video stream stored as JSONB.
type VideoTrack struct {
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
HDR10Plus bool `json:"hdr10_plus,omitempty"`
Profile string `json:"profile,omitempty"`
Level int `json:"level,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Interlaced bool `json:"interlaced"`
FrameRate string `json:"frame_rate,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
PixelFormat string `json:"pixel_format,omitempty"`
ReferenceFrames int `json:"reference_frames,omitempty"`
Title string `json:"title,omitempty"`
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
DVEnhancementLayer string `json:"dv_enhancement_layer,omitempty"` // none, mel, fel, unknown
HDR10Plus bool `json:"hdr10_plus,omitempty"`
Profile string `json:"profile,omitempty"`
Level int `json:"level,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Interlaced bool `json:"interlaced"`
FrameRate string `json:"frame_rate,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
VideoRange string `json:"video_range,omitempty"`
VideoRangeType string `json:"video_range_type,omitempty"`
ColorPrimaries string `json:"color_primaries,omitempty"`
ColorSpace string `json:"color_space,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
PixelFormat string `json:"pixel_format,omitempty"`
ReferenceFrames int `json:"reference_frames,omitempty"`
}
// AudioTrack represents a probed audio stream stored as JSONB.
+24
View File
@@ -16,3 +16,27 @@ func TestPersonKindAudiobookRoles(t *testing.T) {
}
}
}
func TestNormalizeVideoBitDepth(t *testing.T) {
tests := []struct {
name string
explicit int
pixelFormat string
profile string
want int
}{
{name: "explicit wins", explicit: 8, pixelFormat: "yuv420p10le", profile: "Main 10", want: 8},
{name: "hevc ten bit pixel format", pixelFormat: "yuv420p10le", profile: "Main 10", want: 10},
{name: "p010 pixel format", pixelFormat: "p010le", profile: "Main 10", want: 10},
{name: "main ten profile fallback", pixelFormat: "", profile: "Main 10", want: 10},
{name: "ordinary planar eight bit", pixelFormat: "yuv420p", profile: "Main", want: 8},
{name: "unknown remains unknown", pixelFormat: "unknown", profile: "", want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := NormalizeVideoBitDepth(test.explicit, test.pixelFormat, test.profile); got != test.want {
t.Fatalf("NormalizeVideoBitDepth(%d, %q, %q) = %d, want %d", test.explicit, test.pixelFormat, test.profile, got, test.want)
}
})
}
}
+11
View File
@@ -131,6 +131,17 @@ func (p *Planner) PlanSession(sessionID, currentTranscodeURL string, needsTransc
return plan
}
// ReleaseSession removes a provisional node reservation when playback setup
// fails or falls back locally before a node health report can account for it.
func (p *Planner) ReleaseSession(sessionID string) {
if p == nil {
return
}
p.mu.Lock()
delete(p.reserved, sessionID)
p.mu.Unlock()
}
// groupHealth reports, for every group label present in either pool, whether
// all of its enabled members are healthy. Pools only hold enabled nodes, so
// disabled nodes never count against a group.
+16
View File
@@ -59,6 +59,22 @@ func TestPlanTranscodePairsProxyFromSameGroup(t *testing.T) {
}
}
func TestReleaseSessionDropsProvisionalReservation(t *testing.T) {
node := transcodeNode(1, "http://tc-1", nil, 0)
node.MaxJobs = intPtr(1)
f := newFixture(nil, []*Node{node})
if got := f.planner.PlanSession("s1", "", true, 0).TranscodeNode; got == nil {
t.Fatal("first session was not reserved")
}
if got := f.planner.PlanSession("s2", "", true, 0).TranscodeNode; got != nil {
t.Fatalf("second session bypassed reservation: %+v", got)
}
f.planner.ReleaseSession("s1")
if got := f.planner.PlanSession("s2", "", true, 0).TranscodeNode; got == nil {
t.Fatal("released reservation still blocked the node")
}
}
func TestDegradedGroupExcludesItsTranscodeNodes(t *testing.T) {
unhealthyProxy := proxyNode(1, "http://proxy-a", strPtr("rack-a"))
unhealthyProxy.Healthy = false
+305
View File
@@ -0,0 +1,305 @@
package playback
import (
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDescriptorV3 {
if file == nil {
return SourceDescriptorV3{DVEnhancementLayer: EnhancementUnknownV3}
}
source := SourceDescriptorV3{
MediaFileID: file.ID,
Container: normalizeCodecV3(file.Container),
VideoCodec: normalizeCodecV3(file.CodecVideo),
AudioCodec: normalizeCodecV3(file.CodecAudio),
AudioChannels: file.AudioChannels,
BitrateKbps: normalizeBitrateKbpsV3(file.Bitrate),
DVEnhancementLayer: EnhancementNoneV3,
}
if len(file.VideoTracks) > 0 {
track := file.VideoTracks[0]
source.VideoCodec = firstNonEmptyV3(normalizeCodecV3(track.Codec), source.VideoCodec)
source.VideoProfile = strings.ToLower(strings.TrimSpace(track.Profile))
source.VideoLevel = track.Level
source.BitDepth = models.NormalizeVideoBitDepth(track.BitDepth, track.PixelFormat, track.Profile)
source.Width = track.Width
source.Height = track.Height
source.FrameRate = parseFrameRateV3(track.FrameRate)
if track.Bitrate > 0 {
source.BitrateKbps = normalizeBitrateKbpsV3(track.Bitrate)
}
source.DynamicRange = normalizeDynamicRangeV3(track)
source.HDR10Plus = track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+")
source.DVProfile = track.DVProfile
source.DVBLCompatID = track.DVBLCompatID
switch EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer)) {
case EnhancementNoneV3, EnhancementMELV3, EnhancementFELV3, EnhancementUnknownV3:
source.DVEnhancementLayer = EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer))
case "":
// Legacy rows predate the explicit enhancement-layer fields. A
// Profile 7 DOVIWithEL label proves an EL exists but cannot prove
// MEL versus FEL, so keep it unknown rather than misclassifying it
// as a safe single-layer stream.
legacyProfile7EL := track.DVProfile == 7 && strings.Contains(strings.ToLower(track.VideoRangeType), "withel")
if track.DVELPresent || legacyProfile7EL {
source.DVEnhancementLayer = EnhancementUnknownV3
} else {
source.DVEnhancementLayer = EnhancementNoneV3
}
default:
source.DVEnhancementLayer = EnhancementUnknownV3
}
}
if source.Width == 0 || source.Height == 0 {
source.Width, source.Height = dimensionsFromResolutionV3(file.Resolution)
}
if audioIndex >= 0 && audioIndex < len(file.AudioTracks) {
track := file.AudioTracks[audioIndex]
source.AudioCodec = firstNonEmptyV3(normalizeCodecV3(track.Codec), source.AudioCodec)
source.AudioChannels = track.Channels
source.AudioLayout = normalizeLayoutV3(track.Layout)
}
if source.DynamicRange == "" {
if file.HDR {
source.DynamicRange = "hdr_unknown"
} else {
source.DynamicRange = "sdr"
}
}
return source
}
func detailedVideoEligibleV3(source SourceDescriptorV3, request StartRequestV3) bool {
if !HasFeatureV3(request.ClientFeatures, FeatureDetailedDecodeV3) && !HasFeatureV3(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3) {
return false
}
if !detailedVideoEvidenceCompleteV3(source) {
return false
}
for _, capability := range request.Capabilities.VideoDecode {
if !strings.EqualFold(capability.Codec, source.VideoCodec) || !capability.Hardware {
continue
}
if len(capability.Profiles) > 0 && !containsFoldV3(capability.Profiles, source.VideoProfile) {
continue
}
if len(capability.Levels) > 0 && !containsAtLeastV3(capability.Levels, source.VideoLevel) {
continue
}
if len(capability.BitDepths) > 0 && !containsIntV3(capability.BitDepths, source.BitDepth) {
continue
}
if capability.MaxWidth > 0 && source.Width > capability.MaxWidth || capability.MaxHeight > 0 && source.Height > capability.MaxHeight || capability.MaxFrameRate > 0 && source.FrameRate > capability.MaxFrameRate || capability.MaxBitrateKbps > 0 && source.BitrateKbps > capability.MaxBitrateKbps {
continue
}
return true
}
return false
}
func detailedVideoEvidenceCompleteV3(source SourceDescriptorV3) bool {
return source.VideoCodec != "" &&
source.VideoProfile != "" &&
source.VideoLevel > 0 &&
source.BitDepth > 0 &&
source.Width > 0 &&
source.Height > 0 &&
source.FrameRate > 0 &&
source.BitrateKbps > 0
}
func outputRangeEligibleV3(source SourceDescriptorV3, request StartRequestV3) (bool, VideoClaimsV3) {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
claims := VideoClaimsV3{}
switch source.DynamicRange {
case "", "sdr":
return true, claims
case "hdr10":
claims.HDR10 = hdr != nil && hdr.HDR10
return claims.HDR10, claims
case "hdr_unknown":
// Legacy rows only recorded a file-level HDR flag without per-track
// range metadata. HDR10 is by far the most common static-HDR range, so
// an HDR10-capable output treats the source as HDR10 instead of
// refusing playback outright; the planner attaches a degradation
// warning for these assumed-range plans.
claims.HDR10 = hdr != nil && hdr.HDR10
return claims.HDR10, claims
case "hdr10_plus":
claims.HDR10Plus = hdr != nil && hdr.HDR10Plus
return claims.HDR10Plus, claims
case "hlg":
claims.HLG = hdr != nil && hdr.HLG
return claims.HLG, claims
case "dolby_vision":
if source.DVProfile == 7 && source.DVEnhancementLayer == EnhancementUnknownV3 {
claims.DolbyVisionReason = "profile_7_enhancement_layer_unknown"
return false, claims
}
if hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, source.DVProfile) {
claims.DolbyVision = true
claims.DolbyVisionReason = "native_profile_supported"
return true, claims
}
claims.DolbyVisionReason = "native_profile_not_supported"
return false, claims
default:
return false, claims
}
}
func clientSupportsHDR10V3(request StartRequestV3) bool {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
return hdr != nil && hdr.HDR10
}
func audioEligibilityV3(source SourceDescriptorV3, request StartRequestV3) (copyOK, passthrough bool, claim AudioClaimsV3) {
claim.Codec = source.AudioCodec
passthroughCaps := request.ClientPlaybackContext.Output.AudioPassthrough
if passthroughCaps == nil {
passthroughCaps = request.Capabilities.AudioPassthrough
}
if passthroughCaps != nil && containsFoldV3(passthroughCaps.PassthroughCodecs, source.AudioCodec) &&
(HasFeatureV3(request.ClientFeatures, FeatureLayoutPassthrough) || HasFeatureV3(request.ClientPlaybackContext.Features, FeatureLayoutPassthrough)) {
for _, entry := range passthroughCaps.Entries {
if !strings.EqualFold(entry.Codec, source.AudioCodec) || len(entry.ChannelCounts) == 0 || len(entry.Layouts) == 0 ||
!containsIntV3(entry.ChannelCounts, source.AudioChannels) || !containsFoldV3(entry.Layouts, source.AudioLayout) {
continue
}
claim.Passthrough = true
claim.AtmosPreserved = strings.Contains(strings.ToLower(source.AudioLayout), "joc") || strings.Contains(strings.ToLower(source.AudioLayout), "atmos")
claim.Reason = "sink_passthrough_validated"
return true, true, claim
}
}
if containsFoldV3(request.Capabilities.CodecsAudio, source.AudioCodec) {
claim.Reason = "client_decode_supported"
return true, false, claim
}
if passthroughCaps != nil && containsFoldV3(passthroughCaps.PassthroughCodecs, source.AudioCodec) {
claim.Reason = "passthrough_layout_unsupported"
} else {
claim.Reason = "audio_codec_unsupported"
}
return false, false, claim
}
func normalizeDynamicRangeV3(track models.VideoTrack) string {
if track.DVProfile > 0 || strings.Contains(strings.ToLower(track.VideoRangeType), "dovi") || strings.Contains(strings.ToLower(track.DolbyVision), "dolby") {
return "dolby_vision"
}
if track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+") {
return "hdr10_plus"
}
joined := strings.ToLower(strings.Join([]string{track.VideoRange, track.VideoRangeType, track.ColorTransfer}, " "))
if strings.Contains(joined, "hlg") || strings.Contains(joined, "arib-std-b67") {
return "hlg"
}
if strings.Contains(joined, "hdr") || strings.Contains(joined, "smpte2084") || strings.Contains(joined, "pq") {
return "hdr10"
}
if joined == " " || strings.TrimSpace(joined) == "" {
return ""
}
return "sdr"
}
func parseFrameRateV3(value string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if parts := strings.Split(value, "/"); len(parts) == 2 {
n, nErr := strconv.ParseFloat(parts[0], 64)
d, dErr := strconv.ParseFloat(parts[1], 64)
if nErr == nil && dErr == nil && d != 0 {
return n / d
}
}
v, _ := strconv.ParseFloat(value, 64)
return v
}
func normalizeBitrateKbpsV3(value int) int {
if value > 10_000_000 {
return value / 1000
}
return value
}
func dimensionsFromResolutionV3(value string) (int, int) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "4320p", "8k":
return 7680, 4320
case "2160p", "4k", "uhd":
return 3840, 2160
case "1080p", "fhd":
return 1920, 1080
case "720p", "hd":
return 1280, 720
case "480p", "sd":
return 854, 480
default:
return 0, 0
}
}
func normalizeCodecV3(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "h265", "h.265", "x265":
return "hevc"
case "h264", "h.264", "avc", "x264":
return "h264"
case "eac3", "e-ac-3", "ec-3":
return "eac3"
case "truehd", "mlp fba":
return "truehd"
default:
return v
}
}
func normalizeLayoutV3(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func firstNonEmptyV3(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func containsFoldV3(values []string, wanted string) bool {
for _, v := range values {
if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(wanted)) {
return true
}
}
return false
}
func containsIntV3(values []int, wanted int) bool {
for _, v := range values {
if v == wanted {
return true
}
}
return false
}
func containsAtLeastV3(values []int, wanted int) bool {
for _, v := range values {
if v >= wanted {
return true
}
}
return false
}
+113
View File
@@ -0,0 +1,113 @@
package playback
import "strings"
const (
QuirkFireTVAFTKRTHigh10V3 = "android.fire_tv.aftkrt.h264_high10_l52_v1"
QuirkFireTVAFTKRTEAC3HLSV3 = "android.fire_tv.aftkrt.eac3_7_1_hls_audio_adapt_v1"
QuirkFireTVDV8HDR10PlusV3 = "android.fire_tv.dv8_hdr10plus_sei_v1"
)
func high10DecodeOverrideV3(source SourceDescriptorV3, request StartRequestV3) (*AppliedQuirkV3, bool) {
if !deviceQuirkProtocolAvailableV3(request) || !isAmazonModelV3(request, "AFTKRT") ||
!strings.EqualFold(source.VideoCodec, "h264") || !isHigh10ProfileV3(source.VideoProfile) ||
source.BitDepth != 10 || source.VideoLevel <= 0 || source.VideoLevel > 52 ||
source.Width <= 0 || source.Height <= 0 || source.Width > 1920 || source.Height > 1080 {
return nil, false
}
for _, capability := range request.Capabilities.VideoDecode {
if !capability.Hardware || !strings.EqualFold(capability.Codec, "h264") ||
capability.MaxWidth > 0 && source.Width > capability.MaxWidth ||
capability.MaxHeight > 0 && source.Height > capability.MaxHeight ||
capability.MaxFrameRate > 0 && source.FrameRate > capability.MaxFrameRate ||
capability.MaxBitrateKbps > 0 && source.BitrateKbps > capability.MaxBitrateKbps {
continue
}
quirk := AppliedQuirkV3{
ID: QuirkFireTVAFTKRTHigh10V3,
RegistryRevision: DeviceQuirkRegistryRevisionV3,
Action: "positive_decode_override",
Reason: "Exact AFTKRT evidence supports hardware H.264 High 10 through level 5.2 at 1080p.",
}
return &quirk, true
}
return nil, false
}
func hlsEAC3AudioCorrectionV3(source SourceDescriptorV3, request StartRequestV3) (*AppliedQuirkV3, bool) {
if !deviceQuirkProtocolAvailableV3(request) || !isAmazonModelV3(request, "AFTKRT") ||
!strings.EqualFold(source.AudioCodec, "eac3") || source.AudioChannels != 8 {
return nil, false
}
quirk := AppliedQuirkV3{
ID: QuirkFireTVAFTKRTEAC3HLSV3,
RegistryRevision: DeviceQuirkRegistryRevisionV3,
Action: "audio_only_transcode",
Reason: "AFTKRT cannot reliably consume eight-channel E-AC-3 from an HLS MPEG-TS route.",
}
return &quirk, true
}
func dv8HDR10PlusRuntimeCorrectionV3(source SourceDescriptorV3, request StartRequestV3, engine EngineV3) (*AppliedQuirkV3, bool) {
if !deviceQuirkProtocolAvailableV3(request) || source.DVProfile != 8 || !source.HDR10Plus ||
!isAmazonFireTVV3(request) || !engineSupportsFeatureV3(request, engine, ClientDV8HDR10PlusSanitizerV3) {
return nil, false
}
quirk := AppliedQuirkV3{
ID: QuirkFireTVDV8HDR10PlusV3,
RegistryRevision: DeviceQuirkRegistryRevisionV3,
Action: "client_runtime_correction",
Reason: "The native Fire TV Dolby Vision path requires HDR10+ dynamic-metadata SEI removal for hybrid Profile 8 samples.",
}
return &quirk, true
}
func applyCopiedVideoQuirksV3(plan *PlanV3, source SourceDescriptorV3, request StartRequestV3, high10 *AppliedQuirkV3) {
if high10 != nil {
appendAppliedQuirkV3(plan, *high10, "")
}
if quirk, ok := dv8HDR10PlusRuntimeCorrectionV3(source, request, plan.Engine); ok {
appendAppliedQuirkV3(plan, *quirk, ClientDV8HDR10PlusSanitizerV3)
}
}
func appendAppliedQuirkV3(plan *PlanV3, quirk AppliedQuirkV3, runtimeCorrection string) {
for _, existing := range plan.AppliedQuirks {
if existing.ID == quirk.ID && existing.RegistryRevision == quirk.RegistryRevision {
return
}
}
plan.AppliedQuirks = append(plan.AppliedQuirks, quirk)
if runtimeCorrection != "" && !containsFoldV3(plan.RuntimeCorrections, runtimeCorrection) {
plan.RuntimeCorrections = append(plan.RuntimeCorrections, runtimeCorrection)
}
}
func deviceQuirkProtocolAvailableV3(request StartRequestV3) bool {
// Like every other dual-location feature check, either advertisement
// location proves the client speaks the quirk protocol.
return HasFeatureV3(request.ClientFeatures, FeatureDeviceQuirksV3) ||
HasFeatureV3(request.ClientPlaybackContext.Features, FeatureDeviceQuirksV3)
}
func engineSupportsFeatureV3(request StartRequestV3, engine EngineV3, feature string) bool {
value, ok := request.ClientPlaybackContext.Engines[string(engine)]
return ok && value.Enabled && value.SupportedOnDevice && HasFeatureV3(value.Features, feature)
}
func isAmazonModelV3(request StartRequestV3, model string) bool {
device := request.ClientPlaybackContext.Device
return strings.EqualFold(device.Model, model) &&
(strings.EqualFold(device.Manufacturer, "Amazon") || strings.EqualFold(device.Brand, "Amazon"))
}
func isAmazonFireTVV3(request StartRequestV3) bool {
device := request.ClientPlaybackContext.Device
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(device.Model)), "AFT") &&
(strings.EqualFold(device.Manufacturer, "Amazon") || strings.EqualFold(device.Brand, "Amazon"))
}
func isHigh10ProfileV3(profile string) bool {
normalized := strings.NewReplacer(" ", "", "-", "", "_", "").Replace(strings.ToLower(profile))
return normalized == "high10" || normalized == "high10intra"
}
+158
View File
@@ -0,0 +1,158 @@
package playback
import (
"bytes"
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestAFTKRTHigh10OverrideIsExactAndPreservesVideo(t *testing.T) {
file := &models.MediaFile{
ID: 42, FilePath: "/media/high10.mkv", Container: "mkv", CodecVideo: "h264", CodecAudio: "aac",
Resolution: "1080p", Bitrate: 12_000, AudioChannels: 2,
VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "High 10", Level: 52, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 12_000, BitDepth: 10, VideoRange: "SDR", VideoRangeType: "SDR"}},
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}},
}
req := quirkRequestV3()
req.Capabilities.Containers = []string{"mkv"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{51}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || result.PlayMethod != PlayDirect || len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFireTVAFTKRTHigh10V3 {
t.Fatalf("result = %#v", result)
}
req.ClientPlaybackContext.Device.Model = "AFTKA"
withoutExactEvidence := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if withoutExactEvidence.Plan != nil && withoutExactEvidence.Plan.Delivery == DeliveryOriginalHTTPV3 {
t.Fatalf("untested model received override: %#v", withoutExactEvidence.Plan)
}
}
func TestAFTKRTEAC3HLSCorrectionTranscodesAudioOnly(t *testing.T) {
file := &models.MediaFile{
ID: 42, FilePath: "/media/eac3.avi", Container: "avi", CodecVideo: "h264", CodecAudio: "eac3",
Resolution: "1080p", Bitrate: 12_000, AudioChannels: 8,
VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "High", Level: 42, Width: 1920, Height: 1080, FrameRate: "24", Bitrate: 12_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}},
AudioTracks: []models.AudioTrack{{Codec: "eac3", Channels: 8, Layout: "7.1"}},
}
req := quirkRequestV3()
req.Capabilities.Containers = []string{"mkv"}
req.Capabilities.CodecsAudio = []string{"aac", "eac3"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{42}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
req.ClientPlaybackContext.Engines[string(EngineMedia3ProgressiveRemuxV3)] = EngineCapabilityV3{}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxHLSV3 || result.PlayMethod != PlayRemux || result.TargetVideoCodec != "copy" || !result.TranscodeAudio || result.TargetAudioCodec != "aac" || result.Plan.EffectiveRecipe.VideoCodec != "h264" {
t.Fatalf("result = %#v", result)
}
if len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFireTVAFTKRTEAC3HLSV3 {
t.Fatalf("quirks = %#v", result.Plan.AppliedQuirks)
}
wire, err := json.Marshal(result.Plan)
if err != nil {
t.Fatalf("marshal plan: %v", err)
}
if !bytes.Contains(wire, []byte(`"runtime_corrections":[]`)) {
t.Fatalf("runtime corrections must remain an array: %s", wire)
}
req.ClientPlaybackContext.Device.Model = "AFTKA"
withoutQuirk := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if withoutQuirk.Plan == nil || withoutQuirk.Plan.Delivery != DeliveryRemuxHLSV3 {
t.Fatalf("non-quirk HLS result = %#v", withoutQuirk)
}
wire, err = json.Marshal(withoutQuirk.Plan)
if err != nil {
t.Fatalf("marshal non-quirk plan: %v", err)
}
if !bytes.Contains(wire, []byte(`"applied_quirks":[]`)) || !bytes.Contains(wire, []byte(`"runtime_corrections":[]`)) {
t.Fatalf("quirk fields must remain arrays: %s", wire)
}
}
func TestFireTVDV8HDR10PlusCorrectionRequiresAdvertisedRuntime(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 8
file.VideoTracks[0].DVBLCompatID = 1
file.VideoTracks[0].HDR10Plus = true
file.VideoTracks[0].VideoRange = "HDR"
file.VideoTracks[0].VideoRangeType = "DOVI HDR10+"
req := quirkRequestV3()
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, HDR10Plus: true, DolbyVisionProfiles: []int{8}}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
direct := req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
direct.Features = append(direct.Features, ClientDV8HDR10PlusSanitizerV3)
req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = direct
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || len(result.Plan.RuntimeCorrections) != 1 || result.Plan.RuntimeCorrections[0] != ClientDV8HDR10PlusSanitizerV3 || len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFireTVDV8HDR10PlusV3 {
t.Fatalf("result = %#v", result)
}
direct.Features = nil
req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = direct
withoutRuntime := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if withoutRuntime.Plan == nil || len(withoutRuntime.Plan.AppliedQuirks) != 0 || len(withoutRuntime.Plan.RuntimeCorrections) != 0 {
t.Fatalf("unadvertised correction applied: %#v", withoutRuntime.Plan)
}
}
func TestDeviceQuirkProtocolAcceptsEitherFeatureLocation(t *testing.T) {
file := &models.MediaFile{
ID: 42, FilePath: "/media/high10.mkv", Container: "mkv", CodecVideo: "h264", CodecAudio: "aac",
Resolution: "1080p", Bitrate: 12_000, AudioChannels: 2,
VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "High 10", Level: 52, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 12_000, BitDepth: 10, VideoRange: "SDR", VideoRangeType: "SDR"}},
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}},
}
req := quirkRequestV3()
req.Capabilities.Containers = []string{"mkv"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{51}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
// Advertise the quirk protocol in the top-level client features only.
req.ClientPlaybackContext.Features = []string{FeaturePlaybackPlanV3, FeatureMedia3Only, FeatureDetailedDecodeV3}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFireTVAFTKRTHigh10V3 {
t.Fatalf("client-features-only advertisement: %#v", result)
}
// Context-only advertisement is equally sufficient.
contextOnly := quirkRequestV3()
contextOnly.ClientFeatures = []string{FeaturePlaybackPlanV3, FeatureMedia3Only, FeatureDetailedDecodeV3}
if !deviceQuirkProtocolAvailableV3(contextOnly) {
t.Fatal("context-only quirk feature must enable the quirk protocol")
}
neither := quirkRequestV3()
neither.ClientFeatures = []string{FeaturePlaybackPlanV3, FeatureMedia3Only}
neither.ClientPlaybackContext.Features = neither.ClientFeatures
if deviceQuirkProtocolAvailableV3(neither) {
t.Fatal("quirk protocol enabled without any advertisement")
}
}
func TestPlanAttemptKeyV3DeviceQuirkMatchesKotlin(t *testing.T) {
width, height, bitrate := 3840, 2160, 60_000
plan := PlanV3{
PlanID: "plan:quirk", Delivery: DeliveryOriginalHTTPV3,
Stream: StreamV3{Protocol: StreamHTTPProgressiveV3, Container: "mkv"},
EffectiveRecipe: EffectiveRecipeV3{VideoCodec: "hevc", AudioCodec: "eac3", Width: &width, Height: &height, BitrateKbps: &bitrate, DynamicRange: "dolby_vision"},
Subtitle: SubtitleDecisionV3{Mode: SubtitleOffV3},
AppliedQuirks: []AppliedQuirkV3{{ID: QuirkFireTVDV8HDR10PlusV3, RegistryRevision: DeviceQuirkRegistryRevisionV3, Action: "client_runtime_correction"}},
RuntimeCorrections: []string{ClientDV8HDR10PlusSanitizerV3},
}
if got := PlanAttemptKeyV3(plan, 9, nil); got != "v3:8d843bfffeb3adc3" {
t.Fatalf("key = %q", got)
}
}
func quirkRequestV3() StartRequestV3 {
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3, FeatureDeviceQuirksV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3, FeatureDeviceQuirksV3)
req.ClientPlaybackContext.Device = DeviceContextV3{Manufacturer: "Amazon", Brand: "Amazon", Model: "AFTKRT", SDKInt: 30}
return req
}
+6 -5
View File
@@ -36,11 +36,12 @@ var nvencProbeCache = struct {
// HWAccelInfo describes the detected hardware acceleration capability.
type HWAccelInfo struct {
Resolved string `json:"resolved"`
RenderDevices []string `json:"render_devices"`
IntelDetected bool `json:"intel_detected"`
Source string `json:"source"`
NodeURL string `json:"node_url,omitempty"`
Resolved string `json:"resolved"`
RenderDevices []string `json:"render_devices"`
IntelDetected bool `json:"intel_detected"`
Source string `json:"source"`
NodeURL string `json:"node_url,omitempty"`
Transformations []TransformationV3 `json:"transformations,omitempty"`
}
// DetectHWAccel probes this host's GPU hardware and returns structured info.
+101
View File
@@ -0,0 +1,101 @@
package playback
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"hash/fnv"
"sort"
"strconv"
"strings"
)
func DeterministicPlanIDV3(attemptID string, requestedFileID, effectiveFileID int, plan PlanV3) string {
transformations := make([]string, 0, len(plan.Transformations))
for _, transformation := range plan.Transformations {
transformations = append(transformations, transformation.Executor+":"+transformation.Name+":"+transformation.RecipeVersion)
}
sort.Strings(transformations)
parts := []string{
attemptID,
strconv.Itoa(requestedFileID),
strconv.Itoa(effectiveFileID),
string(plan.Delivery),
string(plan.Stream.Protocol),
strings.ToLower(plan.Stream.Container),
strings.ToLower(plan.EffectiveRecipe.VideoCodec),
strings.ToLower(plan.EffectiveRecipe.AudioCodec),
optionalIntV3(plan.EffectiveRecipe.Width),
optionalIntV3(plan.EffectiveRecipe.Height),
optionalIntV3(plan.EffectiveRecipe.BitrateKbps),
strings.ToLower(plan.EffectiveRecipe.DynamicRange),
trackIdentityValueV3(plan.SelectedTracks.Audio),
trackIdentityValueV3(plan.SelectedTracks.Subtitle),
string(plan.Subtitle.Mode),
strings.Join(transformations, ","),
}
parts = appendQuirkIdentityV3(parts, plan)
parts = append(parts, PlanRecipeVersionV3)
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
return "plan:" + hex.EncodeToString(sum[:16])
}
func PlanAttemptKeyV3(plan PlanV3, outputRouteGeneration int64, localMutations []string) string {
transformations := make([]string, 0, len(plan.Transformations))
for _, transformation := range plan.Transformations {
transformations = append(transformations, transformation.Executor+":"+transformation.Name+":"+transformation.RecipeVersion)
}
sort.Strings(transformations)
mutations := append([]string(nil), localMutations...)
sort.Strings(mutations)
parts := []string{
plan.PlanID,
plan.Delivery.KotlinName(),
plan.Stream.Protocol.KotlinName(),
strings.ToLower(plan.Stream.Container),
strings.ToLower(plan.EffectiveRecipe.VideoCodec),
strings.ToLower(plan.EffectiveRecipe.AudioCodec),
optionalIntV3(plan.EffectiveRecipe.Width) + "x" + optionalIntV3(plan.EffectiveRecipe.Height),
optionalIntV3(plan.EffectiveRecipe.BitrateKbps),
strings.ToLower(plan.EffectiveRecipe.DynamicRange),
plan.Subtitle.Mode.KotlinName(),
strings.Join(transformations, ","),
}
parts = appendQuirkIdentityV3(parts, plan)
parts = append(parts,
strconv.FormatInt(outputRouteGeneration, 10),
strings.Join(mutations, ","),
)
canonical := strings.Join(parts, "|")
h := fnv.New64a()
_, _ = h.Write([]byte(canonical))
return fmt.Sprintf("v3:%016x", h.Sum64())
}
func appendQuirkIdentityV3(parts []string, plan PlanV3) []string {
if len(plan.AppliedQuirks) == 0 && len(plan.RuntimeCorrections) == 0 {
return parts
}
quirks := make([]string, 0, len(plan.AppliedQuirks))
for _, quirk := range plan.AppliedQuirks {
quirks = append(quirks, quirk.RegistryRevision+":"+quirk.ID)
}
runtimeCorrections := append([]string(nil), plan.RuntimeCorrections...)
sort.Strings(quirks)
sort.Strings(runtimeCorrections)
return append(parts, strings.Join(quirks, ","), strings.Join(runtimeCorrections, ","))
}
func optionalIntV3(v *int) string {
if v == nil {
return "0"
}
return strconv.Itoa(*v)
}
func trackIdentityValueV3(v *TrackIdentityV3) string {
if v == nil {
return ""
}
return v.ID + ":" + optionalIntV3(v.Index)
}
+718
View File
@@ -0,0 +1,718 @@
package playback
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type PlannerSettingsV3 struct {
TranscodeEnabled bool
Allow4KTranscode bool
}
type PlannerInputV3 struct {
Request StartRequestV3
RequestedFile *models.MediaFile
EffectiveFile *models.MediaFile
AudioTrackIndex int
Settings PlannerSettingsV3
Registry *TransformationRegistryV3
Now time.Time
AttemptedKeys []string
AdditionalSubtitles []SubtitleInventoryEntryV3
}
type PlannerResultV3 struct {
Plan *PlanV3
Terminal *TerminalV3
PlayMethod PlayMethod
TranscodeAudio bool
TargetVideoCodec string
TargetAudioCodec string
TargetResolution string
TargetBitrateKbps int
SubtitleTrackIndex int
SubtitleTransportTrackIndex int
SubtitleBurnIn bool
SubtitleCodec string
}
func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
if input.RequestedFile == nil {
return terminalPlannerResultV3("source_unavailable", "The requested media source is unavailable.", false)
}
file := input.EffectiveFile
if file == nil {
file = input.RequestedFile
}
if input.Now.IsZero() {
input.Now = time.Now()
}
source := SourceDescriptorFromFileV3(file, input.AudioTrackIndex)
// Subtitle renderability is engine-specific, so every candidate route is
// validated against the capabilities of the engine that would execute it.
// The direct engine remains the canonical policy for source-preserving
// routes and for the up-front terminal decision.
subtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3DirectV3, input.AdditionalSubtitles)
if subtitle.Terminal != nil {
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
remuxSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3ProgressiveRemuxV3, input.AdditionalSubtitles)
hlsSubtitle := ResolveSubtitlePolicyV3(file, input.Request, input.Settings.TranscodeEnabled, EngineMedia3HLSV3, input.AdditionalSubtitles)
// A remux route cannot burn subtitles, so it is only viable when its own
// engine can deliver the selected subtitle without one.
remuxSubtitleOK := remuxSubtitle.Terminal == nil && !remuxSubtitle.RequiresBurn
hlsRemuxSubtitleOK := hlsSubtitle.Terminal == nil && !hlsSubtitle.RequiresBurn
quality := ResolveQualityPolicyV3(input.Request, source)
videoOK := detailedVideoEligibleV3(source, input.Request)
var high10Quirk *AppliedQuirkV3
if !videoOK {
if quirk, ok := high10DecodeOverrideV3(source, input.Request); ok {
videoOK = true
high10Quirk = quirk
}
}
rangeOK, videoClaims := outputRangeEligibleV3(source, input.Request)
audioOK, passthrough, audioClaims := audioEligibilityV3(source, input.Request)
if !audioOK && source.AudioCodec == "" && (file == nil || len(file.AudioTracks) == 0) {
// Video-only media has no audio stream to adapt: treating the absence
// as an unsupported codec would force a pointless AAC conversion — or
// a terminal when conversion is unavailable — on a playable file. An
// audio track whose codec merely failed to probe keeps the codec
// gate: converting unknown audio is safer than copying it.
audioOK = true
audioClaims.Reason = "no_audio_track"
}
containerOK := containsFoldV3(input.Request.Capabilities.Containers, source.Container)
dvStripEligible := canStripDolbyVisionToHDR10V3(source, input.Request, input.Registry)
clientDV81Eligible := canClientTransformDV7ToDV81V3(source, input.Request)
clientHDR10Eligible := canClientTransformDV7ToHDR10V3(source, input.Request)
base := PlanV3{
ProtocolVersion: ProtocolV3,
ExpiresAt: NewPlanExpiryV3(input.Now),
SelectedTracks: selectedTracksForPlanV3(file, input.AudioTrackIndex, subtitle),
EffectiveRecipe: recipeFromSourceV3(source),
Claims: ValidationClaimsV3{Video: videoClaims, Audio: audioClaims, Subtitles: subtitle.Claims},
Subtitle: subtitle.Decision,
Transformations: []TransformationV3{},
AppliedQuirks: []AppliedQuirkV3{},
RuntimeCorrections: []string{},
DegradationWarnings: []DegradationWarningV3{},
RequestedMediaFileID: input.RequestedFile.ID,
EffectiveMediaFileID: file.ID,
Source: source,
SubtitleFidelityPolicy: subtitlePolicyNameV3(input.Request.SubtitleFidelityPreference),
Timeline: TimelineV3{SourceStartSeconds: floatOrZeroV3(input.Request.StartPosition), PlayerStartSeconds: floatOrZeroV3(input.Request.StartPosition), CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
base.Claims.Audio.Passthrough = passthrough
if source.DynamicRange == "hdr_unknown" && rangeOK {
base.DegradationWarnings = append(base.DegradationWarnings, DegradationWarningV3{
Code: "hdr_range_assumed_hdr10",
Message: "The source is flagged HDR without precise range metadata and is delivered as HDR10.",
})
}
if !detailedVideoEvidenceCompleteV3(source) {
return terminalPlannerResultV3("source_metadata_incomplete", "The source is missing video metadata required for a validated playback route.", true)
}
// Automatic quality reductions (device resolution limit, bandwidth
// estimate/cap, metered fallback) are best-effort. When the only reason to
// transcode is such a reduction, a validated source-preserving route
// exists, and the transcode itself cannot execute (HDR sources have no
// validated reduced-quality recipe yet, or the client/server lacks the
// transcode route entirely), deliver the source at original quality with a
// degradation warning instead of refusing playback. Explicit user-selected
// rungs keep the existing terminals.
if quality.RequiresTranscode && !quality.ExplicitRung && !subtitle.RequiresBurn && videoOK &&
(rangeOK || dvStripEligible || clientDV81Eligible || clientHDR10Eligible) &&
!videoTranscodeExecutableV3(input, source) {
warnings := append(quality.Warnings, DegradationWarningV3{
Code: "quality_reduction_unavailable",
Message: "Reduced-quality transcoding is unavailable for this source; it is delivered at original quality.",
})
quality = originalQualityResultV3(source)
quality.Warnings = warnings
}
base.DegradationWarnings = append(base.DegradationWarnings, quality.Warnings...)
if quality.RequiresTranscode || !videoOK ||
(!rangeOK && !dvStripEligible && !clientDV81Eligible && !clientHDR10Eligible) ||
(subtitle.RequiresBurn && !remuxSubtitleOK && !hlsRemuxSubtitleOK) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "")
}
// Profile 7 is normalized on the client against the original range-capable
// source. A decoder profile/max-instance claim alone is not proof of native
// dual-layer output, so the default Android route mirrors Silo Apple: P8.1
// base-layer Dolby Vision first, then same-file HDR10.
if source.DVProfile == 7 && quality.PreservesSource && videoOK && containerOK && audioOK && !subtitle.RequiresBurn {
if clientDV81Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "client_dv7_to_dv81"
plan.EffectiveRecipe.DynamicRange = "dolby_vision"
plan.Claims.Video = VideoClaimsV3{DolbyVision: true, DolbyVisionReason: "client_profile7_to_profile81"}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToDV81V3, Executor: "client", RecipeVersion: ClientDVTransformVersionV3,
ValidatedClaims: []string{"profile7_rpu_converted_to_profile81", "hdr10_base_layer_preserved", "enhancement_layer_discarded"},
})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{
Code: "dolby_vision_enhancement_layer_discarded",
Message: "Dolby Vision Profile 7 is played as Profile 8.1 base-layer Dolby Vision; enhancement-layer pixel data is discarded.",
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
if clientHDR10Eligible {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "client_dv7_to_hdr10"
plan.EffectiveRecipe.DynamicRange = "hdr10"
plan.Claims.Video = VideoClaimsV3{HDR10: true}
plan.Transformations = append(plan.Transformations, TransformationV3{
Name: ClientDV7ToHDR10V3, Executor: "client", RecipeVersion: ClientDVTransformVersionV3,
ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"},
})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{
Code: "dolby_vision_removed",
Message: "Dolby Vision Profile 7 is played from the same 4K file as its HDR10 base layer.",
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
}
if source.DVProfile != 7 && engineAvailableV3(input.Request, EngineMedia3DirectV3) && containerOK && videoOK && rangeOK && audioOK && quality.PreservesSource && !subtitle.RequiresBurn {
plan := base
plan.Delivery = DeliveryOriginalHTTPV3
plan.Engine = EngineMedia3DirectV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: source.Container, MIMEType: MimeFromExtension(file.FilePath), Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "validated_original_playback"
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
}
}
// A progressive remux maps only the base-layer video stream, so dual-layer
// Profile 7 can never ship as native Dolby Vision here regardless of the
// client's decoder claims; the validated HDR10 strip is the only eligible
// P7 remux recipe.
remuxRangeOK := rangeOK && source.DVProfile != 7
if videoOK && (remuxRangeOK || dvStripEligible) && (remuxSubtitleOK || hlsRemuxSubtitleOK) {
plan := base
plan.Delivery = DeliveryRemuxProgressiveV3
plan.Engine = EngineMedia3ProgressiveRemuxV3
plan.Stream = StreamV3{Protocol: StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.DecisionReason = "container_normalization"
transcodeAudio := !audioOK
if transcodeAudio {
if input.Registry == nil || !input.Registry.Available("audio_to_aac") {
return terminalPlannerResultV3("audio_conversion_unsupported", "The required validated AAC conversion toolchain is unavailable.", true)
}
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(2)
plan.EffectiveRecipe.AudioLayout = "stereo"
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "server_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "audio_converted", Message: "The selected audio track is converted to AAC stereo."})
plan.DecisionReason = "audio_adaptation"
}
dvStrip := dvStripEligible && (source.DVProfile == 7 || !rangeOK)
if dvStrip {
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"}})
plan.EffectiveRecipe.DynamicRange = "hdr10"
plan.Claims.Video = VideoClaimsV3{HDR10: true}
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "dolby_vision_removed", Message: "Dolby Vision metadata is removed and the validated HDR10 base layer is preserved."})
}
if !dvStrip {
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
}
if remuxSubtitleOK {
plan.Subtitle = remuxSubtitle.Decision
plan.Claims.Subtitles = remuxSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec}
}
}
if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK {
plan.AppliedQuirks = []AppliedQuirkV3{}
plan.RuntimeCorrections = []string{}
plan.Delivery = DeliveryRemuxHLSV3
plan.Engine = EngineMedia3HLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
hlsTranscodeAudio := transcodeAudio
if audioQuirk, ok := hlsEAC3AudioCorrectionV3(source, input.Request); ok && !hlsTranscodeAudio {
if input.Registry == nil || !input.Registry.Available("audio_to_aac") {
return terminalPlannerResultV3("audio_conversion_unsupported", "The device-specific HLS route requires the validated AAC conversion toolchain.", true)
}
hlsTranscodeAudio = true
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.AudioChannels = intPointerV3(2)
plan.EffectiveRecipe.AudioLayout = "stereo"
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Reason: "device_hls_audio_adaptation"}
plan.Transformations = append(plan.Transformations, TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}})
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "audio_converted", Message: "The selected audio track is converted to AAC stereo for this device's HLS route."})
appendAppliedQuirkV3(&plan, *audioQuirk, "")
}
if !dvStrip {
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
}
if hlsTranscodeAudio {
plan.DecisionReason = "hls_audio_adaptation"
} else {
plan.DecisionReason = "hls_packaging_required"
}
plan.Subtitle = hlsSubtitle.Decision
plan.Claims.Subtitles = hlsSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
targetAudio := "copy"
if hlsTranscodeAudio {
targetAudio = "aac"
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec}
}
}
}
if engineAvailableV3(input.Request, EngineMedia3HLSV3) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "copy_routes_exhausted")
}
return terminalPlannerResultV3("adaptation_unavailable", "No validated playback route is available for this source and output route.", false)
}
// planVideoTranscodeV3 always executes on the HLS engine, so the caller must
// pass the subtitle policy resolved against EngineMedia3HLSV3.
func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescriptorV3, quality QualityResultV3, subtitle SubtitlePolicyResultV3, reasonOverride string) PlannerResultV3 {
if !engineAvailableV3(input.Request, EngineMedia3HLSV3) {
return terminalPlannerResultV3("client_hls_unsupported", "The client cannot execute the required HLS adaptation route.", false)
}
if subtitle.Terminal != nil {
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
if !input.Settings.TranscodeEnabled {
reason := "transcoding_disabled"
if subtitle.RequiresBurn {
reason = "subtitle_conversion_unsupported"
}
return terminalPlannerResultV3(reason, "The source requires video adaptation, but transcoding is unavailable.", false)
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return terminalPlannerResultV3("no_alternate_version", "A lower-resolution source is required because 4K transcoding is disabled.", false)
}
if hdrTranscodeUnavailableV3(source) {
return terminalPlannerResultV3("hdr_transcode_unsupported", "This HDR source requires video encoding, but no validated HDR-preserving or tone-map recipe is installed.", false)
}
if input.Registry == nil || !input.Registry.Available("video_to_h264") || !input.Registry.Available("audio_to_aac") {
return terminalPlannerResultV3("conversion_tool_unavailable", "The required validated H.264/AAC conversion toolchain is unavailable.", true)
}
plan := base
plan.Delivery = DeliveryTranscodeHLSV3
plan.Engine = EngineMedia3HLSV3
plan.Stream = StreamV3{Protocol: StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", Headers: map[string]string{}, HeaderRefresh: HeaderRefreshSessionV3}
plan.EffectiveRecipe.VideoCodec = "h264"
plan.EffectiveRecipe.AudioCodec = "aac"
plan.EffectiveRecipe.Width = intPointerV3(quality.Width)
plan.EffectiveRecipe.Height = intPointerV3(quality.Height)
plan.EffectiveRecipe.BitrateKbps = intPointerV3(quality.BitrateKbps)
plan.EffectiveRecipe.AudioChannels = intPointerV3(2)
plan.EffectiveRecipe.AudioLayout = "stereo"
plan.Transformations = append(plan.Transformations,
TransformationV3{Name: "video_to_h264", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_h264_decode"}},
TransformationV3{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1", ValidatedClaims: []string{"media3_audio_decode"}},
)
plan.Claims.Audio = AudioClaimsV3{Codec: "aac", Passthrough: false, AtmosPreserved: false, Reason: "server_audio_adaptation"}
plan.Subtitle = subtitle.Decision
plan.Claims.Subtitles = subtitle.Claims
plan.DecisionReason = quality.Reason
if reasonOverride != "" {
plan.DecisionReason = reasonOverride
}
if subtitle.RequiresBurn {
plan.DecisionReason = "subtitle_burn_in_required"
plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: "subtitle_burn_in", Message: "The selected subtitle is rendered into the video."})
}
plan.EffectiveRecipe.DynamicRange = "sdr"
plan.Claims.Video = VideoClaimsV3{}
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec}
}
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
if source.DynamicRange != "dolby_vision" || !clientSupportsHDR10V3(request) || registry == nil || !registry.Available("server_dv7_to_hdr10") {
return false
}
// Profile 7 always carries an HDR10-viewable base layer. Profile 8 is
// safe only when the DOVI compatibility id explicitly identifies HDR10.
return source.DVProfile == 7 || source.DVProfile == 8 && source.DVBLCompatID == 1
}
func canClientTransformDV7ToDV81V3(source SourceDescriptorV3, request StartRequestV3) bool {
return source.DynamicRange == "dolby_vision" && source.DVProfile == 7 &&
clientSupportsDVProfileV3(request, 8) &&
clientTransformationAvailableV3(request, ClientDV7ToDV81V3, ClientDVTransformVersionV3)
}
func canClientTransformDV7ToHDR10V3(source SourceDescriptorV3, request StartRequestV3) bool {
return source.DynamicRange == "dolby_vision" && source.DVProfile == 7 && clientSupportsHDR10V3(request) &&
clientTransformationAvailableV3(request, ClientDV7ToHDR10V3, ClientDVTransformVersionV3)
}
func clientSupportsDVProfileV3(request StartRequestV3, profile int) bool {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
return hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, profile)
}
func clientTransformationAvailableV3(request StartRequestV3, name, version string) bool {
if !HasFeatureV3(request.ClientFeatures, FeatureClientVideoTransforms) &&
!HasFeatureV3(request.ClientPlaybackContext.Features, FeatureClientVideoTransforms) {
return false
}
engine, ok := request.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
if !ok || !engine.Enabled || !engine.SupportedOnDevice {
return false
}
for _, transformation := range engine.Transformations {
if transformation.Executor == "client" && transformation.Name == name && transformation.RecipeVersion == version {
return true
}
}
return false
}
func is4KSourceV3(file *models.MediaFile, source SourceDescriptorV3) bool {
resolution := ""
if file != nil {
resolution = strings.ToLower(strings.TrimSpace(file.Resolution))
}
return resolution == "2160p" || resolution == "4k" || resolution == "uhd" || source.Width >= 3840 || source.Height >= 2160
}
type QualityResultV3 struct {
Label string
Width int
Height int
BitrateKbps int
PreservesSource bool
RequiresTranscode bool
// ExplicitRung marks a user-selected fixed rung, as opposed to an
// automatic reduction from device limits, bandwidth evidence, or caps.
ExplicitRung bool
Reason string
Warnings []DegradationWarningV3
}
// ResolveQualityPolicyV3 selects the delivery quality for a plan.
//
// bandwidth_cap_kbps is a hard delivery ceiling and is honored in every
// quality mode: "original" delivery is degraded when the source bitrate
// exceeds the cap, fixed rungs are lowered when their ladder bitrate exceeds
// it, and "auto" folds the cap into bandwidth-based rung selection. A metered
// connection with neither a cap nor a bandwidth estimate limits auto
// selection to the conservative 720p rung — the rung auto would pick for a
// mid-range bandwidth estimate — instead of assuming the link can sustain the
// original stream.
func ResolveQualityPolicyV3(request StartRequestV3, source SourceDescriptorV3) QualityResultV3 {
quality, changed := NormalizeQualityV3(request.QualityPreference)
var warnings []DegradationWarningV3
if changed {
warnings = append(warnings, DegradationWarningV3{Code: "quality_preference_normalized", Message: "Unknown quality preference was normalized to auto."})
}
capKbps := optionalValueV3(request.BandwidthCapKbps)
capExceededBySource := capKbps > 0 && source.BitrateKbps > capKbps
if quality == "original" && !capExceededBySource {
result := originalQualityResultV3(source)
result.Warnings = warnings
return result
}
targetHeight := source.Height
reason := "quality_auto_source"
explicitRung := false
capApplied := false
switch {
case quality == "original":
// Only reached when the source bitrate exceeds the cap: the cap is a
// hard ceiling and outranks the original preference.
targetHeight = ladderHeightForBandwidthV3(int(float64(capKbps) * 0.8))
capApplied = true
case quality != "auto":
targetHeight, _ = strconv.Atoi(strings.TrimSuffix(quality, "p"))
reason = "quality_fixed_rung"
explicitRung = true
default:
maxHeight := resolutionHeightV3(request.Capabilities.MaxResolution)
if maxHeight > 0 && (targetHeight == 0 || maxHeight < targetHeight) {
targetHeight = maxHeight
reason = "quality_device_limit"
}
bandwidth := optionalValueV3(request.BandwidthEstimateKbps)
if capKbps > 0 && (bandwidth == 0 || capKbps < bandwidth) {
bandwidth = capKbps
}
if bandwidth > 0 {
targetHeight = minPositiveV3(targetHeight, ladderHeightForBandwidthV3(int(float64(bandwidth)*0.8)))
reason = "quality_bandwidth_limit"
} else if request.Metered {
if capped := minPositiveV3(targetHeight, 720); capped != targetHeight {
targetHeight = capped
reason = "quality_metered_limit"
}
}
}
if targetHeight <= 0 {
targetHeight = 1080
}
if source.Height > 0 && targetHeight > source.Height {
targetHeight = source.Height
}
// The cap also constrains the rung chosen above: a rung that would
// preserve the source is forced down when the source bitrate exceeds the
// cap, and a transcode rung whose ladder bitrate exceeds the cap drops to
// the cap's rung.
if capKbps > 0 && !capApplied {
wouldPreserve := source.Height > 0 && targetHeight >= source.Height
if (wouldPreserve && capExceededBySource) || (!wouldPreserve && ladderBitrateKbpsV3(targetHeight) > capKbps) {
capApplied = true
if capHeight := ladderHeightForBandwidthV3(int(float64(capKbps) * 0.8)); capHeight < targetHeight {
targetHeight = capHeight
}
}
}
if capApplied {
reason = "quality_bandwidth_cap"
warnings = append(warnings, DegradationWarningV3{Code: "bandwidth_cap_applied", Message: "Delivery quality is limited by the configured bandwidth cap."})
}
if source.Height > 0 && targetHeight >= source.Height && !capApplied {
return QualityResultV3{
Label: strconv.Itoa(source.Height) + "p",
Width: source.Width,
Height: source.Height,
BitrateKbps: source.BitrateKbps,
PreservesSource: true,
ExplicitRung: explicitRung,
Reason: reason,
Warnings: warnings,
}
}
label := resolutionLabelV3(targetHeight)
effectiveHeight := resolutionHeightV3(label)
if source.Height > 0 && effectiveHeight > source.Height {
effectiveHeight = source.Height
label = resolutionLabelV3(effectiveHeight)
}
width, bitrate := qualityDimensionsV3(effectiveHeight, source.Width, source.Height)
if capKbps > 0 && bitrate > capKbps {
// The ladder has no rung below 480p, so a cap under the lowest rung's
// bitrate is honored by lowering the encode target directly: the cap
// is a hard delivery ceiling, never advisory.
bitrate = capKbps
}
result := QualityResultV3{Label: label, Width: width, Height: effectiveHeight, BitrateKbps: bitrate, PreservesSource: !capApplied && source.Height > 0 && effectiveHeight >= source.Height, ExplicitRung: explicitRung, Reason: reason, Warnings: warnings}
result.RequiresTranscode = !result.PreservesSource
return result
}
func originalQualityResultV3(source SourceDescriptorV3) QualityResultV3 {
return QualityResultV3{Label: resolutionLabelV3(source.Height), Width: source.Width, Height: source.Height, BitrateKbps: source.BitrateKbps, PreservesSource: true, Reason: "quality_original"}
}
// hdrTranscodeUnavailableV3 mirrors planVideoTranscodeV3's terminal
// condition: no validated HDR-preserving or tone-map transcode recipe exists.
func hdrTranscodeUnavailableV3(source SourceDescriptorV3) bool {
return source.DynamicRange != "" && source.DynamicRange != "sdr"
}
// videoTranscodeExecutableV3 mirrors planVideoTranscodeV3's terminal
// preconditions: it reports whether a validated video transcode of this
// source could actually run for this client and configuration.
func videoTranscodeExecutableV3(input PlannerInputV3, source SourceDescriptorV3) bool {
if !engineAvailableV3(input.Request, EngineMedia3HLSV3) || !input.Settings.TranscodeEnabled {
return false
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
return false
}
if hdrTranscodeUnavailableV3(source) {
return false
}
return input.Registry != nil && input.Registry.Available("video_to_h264") && input.Registry.Available("audio_to_aac")
}
func recipeFromSourceV3(source SourceDescriptorV3) EffectiveRecipeV3 {
return EffectiveRecipeV3{VideoCodec: source.VideoCodec, AudioCodec: source.AudioCodec, Width: intPointerV3(source.Width), Height: intPointerV3(source.Height), FrameRate: floatPointerV3(source.FrameRate), BitrateKbps: intPointerV3(source.BitrateKbps), DynamicRange: source.DynamicRange, AudioChannels: intPointerV3(source.AudioChannels), AudioLayout: source.AudioLayout}
}
func selectedTracksForPlanV3(file *models.MediaFile, audioIndex int, subtitle SubtitlePolicyResultV3) SelectedTracksV3 {
selected := SelectedTracksV3{}
if file != nil && audioIndex >= 0 && audioIndex < len(file.AudioTracks) {
index := audioIndex
selected.Audio = &TrackIdentityV3{ID: TrackIDV3(file.ID, "audio", audioIndex), Index: &index}
}
if file != nil && subtitle.SelectedIndex >= 0 {
index := subtitle.SelectedIndex
selected.Subtitle = &TrackIdentityV3{ID: TrackIDV3(file.ID, "subtitle", index), Index: &index}
}
return selected
}
func finalizePlanIdentityV3(plan *PlanV3, attemptID string) {
plan.PlanID = DeterministicPlanIDV3(attemptID, plan.RequestedMediaFileID, plan.EffectiveMediaFileID, *plan)
}
// planAttemptedV3 compares FNV-hex attempt keys exactly after trimming
// whitespace; the keys are case-sensitive hashes, not free-form labels.
func planAttemptedV3(plan PlanV3, generation int64, attempted []string) bool {
wanted := PlanAttemptKeyV3(plan, generation, nil)
for _, key := range attempted {
if strings.TrimSpace(key) == wanted {
return true
}
}
return false
}
func terminalPlannerResultV3(reason, message string, retryable bool) PlannerResultV3 {
return PlannerResultV3{Terminal: &TerminalV3{Reason: reason, Message: message, Retryable: retryable}, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
func subtitlePolicyNameV3(f SubtitleFidelityV3) string {
if f == SubtitleFidelityPreserveV3 {
return "require_authored_fidelity"
}
return "allow_simplified_rendering"
}
func floatOrZeroV3(v *float64) float64 {
if v == nil {
return 0
}
return *v
}
func intPointerV3(v int) *int {
if v <= 0 {
return nil
}
value := v
return &value
}
func floatPointerV3(v float64) *float64 {
if v <= 0 {
return nil
}
value := v
return &value
}
func optionalValueV3(v *int) int {
if v == nil {
return 0
}
return *v
}
func resolutionHeightV3(v string) int {
value, _ := strconv.Atoi(strings.TrimSuffix(strings.ToLower(v), "p"))
if strings.EqualFold(v, "4k") {
return 2160
}
return value
}
func resolutionLabelV3(h int) string {
switch {
case h >= 2160:
return "2160p"
case h >= 1080:
return "1080p"
case h >= 720:
return "720p"
default:
return "480p"
}
}
func ladderHeightForBandwidthV3(kbps int) int {
switch {
case kbps >= 20_000:
return 2160
case kbps >= 8_000:
return 1080
case kbps >= 4_000:
return 720
default:
return 480
}
}
func minPositiveV3(a, b int) int {
if a <= 0 {
return b
}
if b <= 0 || a < b {
return a
}
return b
}
// ladderBitrateKbpsV3 matches the established web ladder's standard shared
// rungs; 2160p is the v3-only extension until the web menu exposes a 4K
// transcode tier.
func ladderBitrateKbpsV3(height int) int {
bitrates := map[int]int{480: 1_500, 720: 2_000, 1080: 6_000, 2160: 20_000}
return bitrates[resolutionHeightV3(resolutionLabelV3(height))]
}
func qualityDimensionsV3(height, sourceWidth, sourceHeight int) (int, int) {
rung := resolutionHeightV3(resolutionLabelV3(height))
width := 0
if sourceWidth > 0 && sourceHeight > 0 {
width = sourceWidth * rung / sourceHeight
width -= width % 2
}
if width == 0 {
width, _ = dimensionsFromResolutionV3(resolutionLabelV3(rung))
}
return width, ladderBitrateKbpsV3(rung)
}
func SortedTransformationNamesV3(values []TransformationV3) []string {
result := make([]string, 0, len(values))
for _, v := range values {
result = append(result, v.Name)
}
sort.Strings(result)
return result
}
func engineAvailableV3(request StartRequestV3, engine EngineV3) bool {
capability, ok := request.ClientPlaybackContext.Engines[string(engine)]
if !ok {
return false
}
return capability.Enabled && capability.SupportedOnDevice
}
func ExplainPlannerResultV3(result PlannerResultV3) string {
if result.Plan != nil {
return fmt.Sprintf("%s:%s", result.Plan.Delivery, result.Plan.DecisionReason)
}
if result.Terminal != nil {
return "terminal:" + result.Terminal.Reason
}
return "invalid"
}
+339
View File
@@ -0,0 +1,339 @@
package planstore
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/playback"
)
type Postgres struct {
db *pgxpool.Pool
}
func NewPostgres(db *pgxpool.Pool) *Postgres { return &Postgres{db: db} }
// SessionLockCapacity reports how many AcquireSessionLock holders the
// underlying pool can sustain concurrently. Each holder pins one pooled
// connection for its advisory-lock transaction while issuing further store
// queries from the same pool, so the bound leaves at least half the pool free
// for those queries and for the rest of the application.
func (s *Postgres) SessionLockCapacity() int {
if s == nil || s.db == nil {
return 0
}
capacity := int(s.db.Config().MaxConns) / 2
if capacity < 1 {
capacity = 1
}
return capacity
}
func (s *Postgres) AcquireSessionLock(ctx context.Context, sessionID string) (func(), error) {
conn, err := s.db.Acquire(ctx)
if err != nil {
return nil, err
}
tx, err := conn.Begin(ctx)
if err != nil {
conn.Release()
return nil, err
}
release := func() {
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := tx.Rollback(rollbackCtx)
cancel()
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
// Closing the physical connection is the fail-safe for an uncertain
// rollback; PostgreSQL releases every transaction advisory lock when
// the backend connection closes.
closeCtx, closeCancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = conn.Conn().Close(closeCtx)
closeCancel()
}
conn.Release()
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sessionID); err != nil {
release()
return nil, err
}
var once sync.Once
return func() {
once.Do(release)
}, nil
}
func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecordV3) error {
planJSON, err := json.Marshal(record.CurrentPlan)
if err != nil {
return err
}
requestJSON, err := json.Marshal(record.NormalizedRequest)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
// Expired rows linger for up to an hour until CleanupExpired runs; they
// must not wedge a legitimate attempt-ID or session reuse into a
// conflict that the recovery lookup (which filters expired rows) can
// never resolve.
if _, err := tx.Exec(ctx, `
DELETE FROM playback_v3_attempts
WHERE (playback_attempt_id = $1 OR session_id = $2::uuid) AND expires_at <= NOW()`,
record.PlaybackAttemptID, record.SessionID); err != nil {
return err
}
result, err := tx.Exec(ctx, `
INSERT INTO playback_v3_attempts (
playback_attempt_id, session_id, user_id, profile_id,
requested_media_file_id, effective_media_file_id,
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING`,
record.PlaybackAttemptID, record.SessionID, record.UserID, record.ProfileID,
record.RequestedMediaFileID, record.EffectiveMediaFileID,
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
// An attempt-ID reused with different input is an idempotency
// violation, not a replayable duplicate.
var digest string
err := tx.QueryRow(ctx, `
SELECT request_digest FROM playback_v3_attempts
WHERE playback_attempt_id = $1 AND expires_at > NOW()`, record.PlaybackAttemptID).Scan(&digest)
if err == nil && digest != "" && record.RequestDigest != "" && digest != record.RequestDigest {
return playback.ErrIdempotencyKeyReusedV3
}
return playback.ErrPlaybackAttemptExistsV3
}
return tx.Commit(ctx)
}
func (s *Postgres) GetAttempt(ctx context.Context, sessionID string) (*playback.AttemptRecordV3, error) {
return s.getAttempt(ctx, "session_id = $1::uuid", sessionID)
}
func (s *Postgres) GetAttemptByPlaybackAttemptID(ctx context.Context, attemptID string) (*playback.AttemptRecordV3, error) {
return s.getAttempt(ctx, "playback_attempt_id = $1", attemptID)
}
func (s *Postgres) GetAttemptIdentity(ctx context.Context, sessionID string) (*playback.AttemptIdentityV3, error) {
return s.getAttemptIdentity(ctx, "session_id = $1::uuid", sessionID)
}
func (s *Postgres) GetAttemptIdentityByPlaybackAttemptID(ctx context.Context, attemptID string) (*playback.AttemptIdentityV3, error) {
return s.getAttemptIdentity(ctx, "playback_attempt_id = $1", attemptID)
}
// getAttemptIdentity fetches only the ownership columns; route-event
// authorization runs per event and must not pay for the plan JSONB decode.
func (s *Postgres) getAttemptIdentity(ctx context.Context, predicate string, value any) (*playback.AttemptIdentityV3, error) {
var identity playback.AttemptIdentityV3
err := s.db.QueryRow(ctx, `
SELECT playback_attempt_id, session_id::text, user_id, profile_id
FROM playback_v3_attempts
WHERE `+predicate+` AND expires_at > NOW()`, value).Scan(
&identity.PlaybackAttemptID, &identity.SessionID, &identity.UserID, &identity.ProfileID,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, playback.ErrSessionNotFound
}
if err != nil {
return nil, err
}
return &identity, nil
}
func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) (*playback.AttemptRecordV3, error) {
var record playback.AttemptRecordV3
var planJSON, requestJSON []byte
err := s.db.QueryRow(ctx, `
SELECT playback_attempt_id, session_id::text, user_id, profile_id,
requested_media_file_id, effective_media_file_id,
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
FROM playback_v3_attempts
WHERE `+predicate+` AND expires_at > NOW()`, value).Scan(
&record.PlaybackAttemptID, &record.SessionID, &record.UserID, &record.ProfileID,
&record.RequestedMediaFileID, &record.EffectiveMediaFileID,
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, playback.ErrSessionNotFound
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(planJSON, &record.CurrentPlan); err != nil {
return nil, err
}
if err := json.Unmarshal(requestJSON, &record.NormalizedRequest); err != nil {
return nil, err
}
return &record, nil
}
func (s *Postgres) BeginReplan(ctx context.Context, sessionID, requestID, digest, baseReplanRequestID string, leaseUntil time.Time) (playback.ReplanLeaseV3, error) {
// One retry: if a concurrent writer wins the insert race (possible only
// when a caller skips the advisory session lock), re-read its row and
// resolve to a replay/in-flight lease instead of surfacing a raw 23505.
for attempt := 0; ; attempt++ {
lease, retry, err := s.beginReplanOnce(ctx, sessionID, requestID, digest, baseReplanRequestID, leaseUntil)
if retry && attempt == 0 {
continue
}
return lease, err
}
}
func (s *Postgres) beginReplanOnce(ctx context.Context, sessionID, requestID, digest, baseReplanRequestID string, leaseUntil time.Time) (playback.ReplanLeaseV3, bool, error) {
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return playback.ReplanLeaseV3{}, false, err
}
defer func() { _ = tx.Rollback(ctx) }()
var existingDigest, existingBase, state string
var existingLease time.Time
var response []byte
err = tx.QueryRow(ctx, `
SELECT request_digest, base_replan_request_id, state, lease_expires_at, response
FROM playback_v3_replans
WHERE session_id = $1::uuid AND replan_request_id = $2
FOR UPDATE`, sessionID, requestID).Scan(&existingDigest, &existingBase, &state, &existingLease, &response)
if errors.Is(err, pgx.ErrNoRows) {
_, err = tx.Exec(ctx, `
INSERT INTO playback_v3_replans (session_id, replan_request_id, request_digest, base_replan_request_id, lease_expires_at)
VALUES ($1::uuid, $2, $3, $4, $5)`, sessionID, requestID, digest, baseReplanRequestID, leaseUntil)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return playback.ReplanLeaseV3{}, true, nil
}
return playback.ReplanLeaseV3{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return playback.ReplanLeaseV3{}, false, err
}
return playback.ReplanLeaseV3{State: playback.ReplanLeaseOwnedV3}, false, nil
}
if err != nil {
return playback.ReplanLeaseV3{}, false, err
}
if existingDigest != digest {
return playback.ReplanLeaseV3{}, false, playback.ErrIdempotencyKeyReusedV3
}
if state == "completed" {
if err := tx.Commit(ctx); err != nil {
return playback.ReplanLeaseV3{}, false, err
}
return playback.ReplanLeaseV3{State: playback.ReplanLeaseCompletedV3, Response: response}, false, nil
}
if time.Now().Before(existingLease) {
if err := tx.Commit(ctx); err != nil {
return playback.ReplanLeaseV3{}, false, err
}
return playback.ReplanLeaseV3{State: playback.ReplanLeaseInFlightV3}, false, nil
}
if existingBase != baseReplanRequestID {
return playback.ReplanLeaseV3{}, false, playback.ErrStaleReplanLeaseV3
}
_, err = tx.Exec(ctx, `UPDATE playback_v3_replans SET lease_expires_at = $3, updated_at = NOW() WHERE session_id = $1::uuid AND replan_request_id = $2`, sessionID, requestID, leaseUntil)
if err != nil {
return playback.ReplanLeaseV3{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return playback.ReplanLeaseV3{}, false, err
}
return playback.ReplanLeaseV3{State: playback.ReplanLeaseOwnedV3}, false, nil
}
func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, baseReplanRequestID string, response json.RawMessage, record playback.AttemptRecordV3) error {
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
planJSON, err := json.Marshal(record.CurrentPlan)
if err != nil {
return err
}
requestJSON, err := json.Marshal(record.NormalizedRequest)
if err != nil {
return err
}
// The base-revision predicate makes the commit a true compare-and-swap:
// under the advisory session lock it never fails, but a skipped or broken
// lock must surface as a conflict rather than silently last-writer-win
// the durable plan.
attemptResult, err := tx.Exec(ctx, `
UPDATE playback_v3_attempts SET
effective_media_file_id = $2, current_plan_id = $3,
current_replan_request_id = $4, current_plan = $5, normalized_request = $6, expires_at = $7, updated_at = NOW()
WHERE session_id = $1::uuid AND current_replan_request_id = $8`,
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
if err != nil {
return err
}
if attemptResult.RowsAffected() != 1 {
var exists bool
if scanErr := tx.QueryRow(ctx, `SELECT true FROM playback_v3_attempts WHERE session_id = $1::uuid`, sessionID).Scan(&exists); scanErr == nil {
return playback.ErrReplanSupersededV3
}
return playback.ErrSessionNotFound
}
replanResult, err := tx.Exec(ctx, `
UPDATE playback_v3_replans SET state = 'completed', response = $3, updated_at = NOW()
WHERE session_id = $1::uuid AND replan_request_id = $2`, sessionID, requestID, response)
if err != nil {
return err
}
if replanResult.RowsAffected() != 1 {
return playback.ErrSessionNotFound
}
return tx.Commit(ctx)
}
func (s *Postgres) RecordRouteEvent(ctx context.Context, record playback.RouteEventRecordV3) error {
if record.Diagnostics == nil {
record.Diagnostics = map[string]string{}
}
diagnostics, err := json.Marshal(record.Diagnostics)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `
INSERT INTO playback_route_events (
playback_attempt_id, session_id, plan_id, plan_attempt_id, plan_attempt_key,
event, failure_classification, fallback_reason, output_route_generation,
diagnostics, user_id, profile_id, client_name, client_version, client_model
) VALUES ($1, NULLIF($2, '')::uuid, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''),
$6, NULLIF($7, ''), NULLIF($8, ''), $9, $10, $11, $12,
NULLIF($13, ''), NULLIF($14, ''), NULLIF($15, ''))`,
record.PlaybackAttemptID, record.SessionID, record.PlanID, record.PlanAttemptID, record.PlanAttemptKey,
record.Event, record.FailureClassification, record.FallbackReason, record.OutputRouteGeneration,
diagnostics, record.UserID, record.ProfileID, record.ClientName, record.ClientVersion, record.ClientModel)
return err
}
func (s *Postgres) CleanupExpired(ctx context.Context, now time.Time) (int64, error) {
if _, err := s.db.Exec(ctx, `DELETE FROM playback_route_events WHERE received_at < $1`, now.Add(-30*24*time.Hour)); err != nil {
return 0, err
}
result, err := s.db.Exec(ctx, `DELETE FROM playback_v3_attempts WHERE expires_at <= $1`, now)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
@@ -0,0 +1,601 @@
package planstore
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/playback"
)
// planstoreFixture holds the minimal FK graph (user + media files) that
// playback_v3_attempts and playback_route_events rows require.
type planstoreFixture struct {
pool *pgxpool.Pool
userID int
mediaFileID int
altFileID int
}
// newPlanstoreFixture connects to SILO_TEST_DATABASE_URL (skipping when
// unset), verifies the v3 migrations are applied, and inserts the fixture
// rows every attempt/event insert depends on. Cleanup deletes everything the
// tests wrote so reruns against the same database stay green.
func newPlanstoreFixture(t *testing.T) *planstoreFixture {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.playback_v3_attempts')::text`).Scan(&tableName); err != nil {
t.Fatalf("check playback_v3_attempts table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the playback protocol v3 migration")
}
var hasRevision bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'playback_v3_attempts' AND column_name = 'current_replan_request_id'
)`).Scan(&hasRevision); err != nil {
t.Fatalf("check current_replan_request_id column: %v", err)
}
if !hasRevision {
t.Skip("test database has not applied the playback v3 attempt revision migration")
}
f := &planstoreFixture{pool: pool}
unique := fmt.Sprintf("planstore-test-%d", time.Now().UnixNano())
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name) VALUES ('movies', $1) RETURNING id`, unique).Scan(&folderID); err != nil {
t.Fatalf("insert fixture media folder: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO users (username) VALUES ($1) RETURNING id`, unique).Scan(&f.userID); err != nil {
t.Fatalf("insert fixture user: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (media_folder_id, file_path) VALUES ($1, $2) RETURNING id`,
folderID, "/fixtures/"+unique+"/movie.mkv").Scan(&f.mediaFileID); err != nil {
t.Fatalf("insert fixture media file: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (media_folder_id, file_path) VALUES ($1, $2) RETURNING id`,
folderID, "/fixtures/"+unique+"/movie-alt.mkv").Scan(&f.altFileID); err != nil {
t.Fatalf("insert alternate fixture media file: %v", err)
}
t.Cleanup(func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Attempts cascade to replans; deleting the user cascades any attempt
// or route event a failed subtest left behind; the folder cascades the
// media files.
_, _ = pool.Exec(cleanupCtx, `DELETE FROM playback_route_events WHERE user_id = $1`, f.userID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM playback_v3_attempts WHERE user_id = $1`, f.userID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, f.userID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
return f
}
func (f *planstoreFixture) attemptRecord(sessionID, attemptID, digest string) playback.AttemptRecordV3 {
return playback.AttemptRecordV3{
PlaybackAttemptID: attemptID,
SessionID: sessionID,
UserID: f.userID,
ProfileID: "profile-1",
RequestedMediaFileID: f.mediaFileID,
EffectiveMediaFileID: f.mediaFileID,
CurrentPlanID: "plan-1",
CurrentReplanRequestID: "",
CurrentPlan: playback.PlanV3{
ProtocolVersion: 3,
PlanID: "plan-1",
SessionID: sessionID,
DecisionReason: "direct_play",
RequestedMediaFileID: f.mediaFileID,
EffectiveMediaFileID: f.mediaFileID,
},
NormalizedRequest: playback.StartRequestV3{
ProtocolVersion: 3,
FileID: f.mediaFileID,
ProfileID: "profile-1",
PlaybackAttemptID: attemptID,
QualityPreference: "auto",
},
RequestDigest: digest,
ExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Microsecond),
}
}
func (f *planstoreFixture) expireAttempt(t *testing.T, attemptID string) {
t.Helper()
tag, err := f.pool.Exec(context.Background(), `
UPDATE playback_v3_attempts SET expires_at = NOW() - INTERVAL '1 minute'
WHERE playback_attempt_id = $1`, attemptID)
if err != nil {
t.Fatalf("expire attempt %s: %v", attemptID, err)
}
if tag.RowsAffected() != 1 {
t.Fatalf("expire attempt %s: affected %d rows", attemptID, tag.RowsAffected())
}
}
// mustJSON canonicalizes a value through encoding/json so structs that
// round-trip via JSONB can be compared without tripping on nil-vs-empty
// map/slice differences.
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return data
}
func TestPostgresPlanStore(t *testing.T) {
f := newPlanstoreFixture(t)
store := NewPostgres(f.pool)
ctx := context.Background()
// Regression test for the CHECK-constraint drift: every event name the
// code emits must be accepted by the playback_route_events CHECK in the
// real schema.
t.Run("RecordRouteEventAcceptsAllEventNames", func(t *testing.T) {
sessionID := uuid.NewString()
names := playback.RouteEventNamesV3()
if len(names) == 0 {
t.Fatal("RouteEventNamesV3 returned no events")
}
for _, name := range names {
err := store.RecordRouteEvent(ctx, playback.RouteEventRecordV3{
RouteEventV3: playback.RouteEventV3{
ProtocolVersion: 3,
PlaybackAttemptID: "att-events-" + sessionID,
SessionID: sessionID,
PlanID: "plan-1",
PlanAttemptID: "plan-attempt-1",
PlanAttemptKey: "plan-attempt-key-1",
Event: name,
FailureClassification: "decode_error",
FallbackReason: "test",
OutputRouteGeneration: 1,
Diagnostics: map[string]string{"source": "planstore-test"},
},
UserID: f.userID,
ProfileID: "profile-1",
ClientName: "planstore-test",
ClientVersion: "1.0",
ClientModel: "test-model",
})
if err != nil {
t.Errorf("RecordRouteEvent(%q) rejected by real schema: %v", name, err)
}
}
var count int
if err := f.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM playback_route_events WHERE session_id = $1::uuid`, sessionID).Scan(&count); err != nil {
t.Fatalf("count route events: %v", err)
}
if count != len(names) {
t.Fatalf("persisted %d route events, want %d", count, len(names))
}
})
t.Run("SaveAttemptIdempotency", func(t *testing.T) {
sessionID := uuid.NewString()
attemptID := "att-save-" + sessionID
record := f.attemptRecord(sessionID, attemptID, "digest-a")
if err := store.SaveAttempt(ctx, record); err != nil {
t.Fatalf("fresh SaveAttempt: %v", err)
}
// Exact replay of the same attempt-ID and digest.
if err := store.SaveAttempt(ctx, record); !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
t.Fatalf("same-digest replay: got %v, want ErrPlaybackAttemptExistsV3", err)
}
// Same attempt-ID reused with different input (digest) is an
// idempotency violation, not a replay.
conflicting := f.attemptRecord(uuid.NewString(), attemptID, "digest-b")
if err := store.SaveAttempt(ctx, conflicting); !errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
t.Fatalf("different-digest reuse: got %v, want ErrIdempotencyKeyReusedV3", err)
}
// Once the original row expires, the pre-delete path must clear it so
// the attempt-ID becomes reusable.
f.expireAttempt(t, attemptID)
if err := store.SaveAttempt(ctx, record); err != nil {
t.Fatalf("SaveAttempt after expiry should reclaim the attempt-ID: %v", err)
}
})
t.Run("GetAttemptRoundTrip", func(t *testing.T) {
sessionID := uuid.NewString()
attemptID := "att-get-" + sessionID
record := f.attemptRecord(sessionID, attemptID, "digest-get")
if err := store.SaveAttempt(ctx, record); err != nil {
t.Fatalf("SaveAttempt: %v", err)
}
for name, fetch := range map[string]func() (*playback.AttemptRecordV3, error){
"GetAttempt": func() (*playback.AttemptRecordV3, error) { return store.GetAttempt(ctx, sessionID) },
"GetAttemptByPlaybackAttemptID": func() (*playback.AttemptRecordV3, error) { return store.GetAttemptByPlaybackAttemptID(ctx, attemptID) },
} {
got, err := fetch()
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if got.PlaybackAttemptID != attemptID || got.SessionID != sessionID {
t.Fatalf("%s identity mismatch: %+v", name, got)
}
if got.UserID != record.UserID || got.ProfileID != record.ProfileID {
t.Fatalf("%s ownership mismatch: %+v", name, got)
}
if got.RequestedMediaFileID != record.RequestedMediaFileID || got.EffectiveMediaFileID != record.EffectiveMediaFileID {
t.Fatalf("%s media file mismatch: %+v", name, got)
}
if got.CurrentPlanID != record.CurrentPlanID || got.CurrentReplanRequestID != record.CurrentReplanRequestID {
t.Fatalf("%s plan revision mismatch: %+v", name, got)
}
if got.RequestDigest != record.RequestDigest {
t.Fatalf("%s request_digest = %q, want %q", name, got.RequestDigest, record.RequestDigest)
}
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) {
t.Fatalf("%s plan JSON did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan))
}
if !bytes.Equal(mustJSON(t, got.NormalizedRequest), mustJSON(t, record.NormalizedRequest)) {
t.Fatalf("%s normalized request JSON did not round-trip", name)
}
if diff := got.ExpiresAt.Sub(record.ExpiresAt); diff < -time.Millisecond || diff > time.Millisecond {
t.Fatalf("%s expires_at drifted by %v", name, diff)
}
}
identity, err := store.GetAttemptIdentity(ctx, sessionID)
if err != nil {
t.Fatalf("GetAttemptIdentity: %v", err)
}
byAttempt, err := store.GetAttemptIdentityByPlaybackAttemptID(ctx, attemptID)
if err != nil {
t.Fatalf("GetAttemptIdentityByPlaybackAttemptID: %v", err)
}
for name, got := range map[string]*playback.AttemptIdentityV3{"bySession": identity, "byAttempt": byAttempt} {
if got.PlaybackAttemptID != attemptID || got.SessionID != sessionID ||
got.UserID != f.userID || got.ProfileID != "profile-1" {
t.Fatalf("%s identity ownership mismatch: %+v", name, got)
}
}
// Expired rows must be invisible to every read path.
f.expireAttempt(t, attemptID)
if _, err := store.GetAttempt(ctx, sessionID); !errors.Is(err, playback.ErrSessionNotFound) {
t.Fatalf("GetAttempt on expired row: got %v, want ErrSessionNotFound", err)
}
if _, err := store.GetAttemptByPlaybackAttemptID(ctx, attemptID); !errors.Is(err, playback.ErrSessionNotFound) {
t.Fatalf("GetAttemptByPlaybackAttemptID on expired row: got %v, want ErrSessionNotFound", err)
}
if _, err := store.GetAttemptIdentity(ctx, sessionID); !errors.Is(err, playback.ErrSessionNotFound) {
t.Fatalf("GetAttemptIdentity on expired row: got %v, want ErrSessionNotFound", err)
}
if _, err := store.GetAttemptIdentityByPlaybackAttemptID(ctx, attemptID); !errors.Is(err, playback.ErrSessionNotFound) {
t.Fatalf("GetAttemptIdentityByPlaybackAttemptID on expired row: got %v, want ErrSessionNotFound", err)
}
})
t.Run("BeginReplanLifecycle", func(t *testing.T) {
sessionID := uuid.NewString()
attemptID := "att-replan-" + sessionID
if err := store.SaveAttempt(ctx, f.attemptRecord(sessionID, attemptID, "digest-replan")); err != nil {
t.Fatalf("SaveAttempt: %v", err)
}
future := time.Now().Add(time.Minute)
// New replan request: caller owns the lease.
lease, err := store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-1", "", future)
if err != nil {
t.Fatalf("BeginReplan new: %v", err)
}
if lease.State != playback.ReplanLeaseOwnedV3 {
t.Fatalf("BeginReplan new state = %q, want owned", lease.State)
}
// Same request-ID with different input.
if _, err := store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-other", "", future); !errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
t.Fatalf("digest mismatch: got %v, want ErrIdempotencyKeyReusedV3", err)
}
// Active unexpired lease held by someone else.
lease, err = store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-1", "", future)
if err != nil {
t.Fatalf("BeginReplan in-flight: %v", err)
}
if lease.State != playback.ReplanLeaseInFlightV3 {
t.Fatalf("BeginReplan in-flight state = %q, want in_flight", lease.State)
}
// Completed replan replays the stored response.
completed := f.attemptRecord(sessionID, attemptID, "digest-replan")
completed.CurrentPlanID = "plan-2"
completed.CurrentReplanRequestID = "rq-1"
completed.CurrentPlan.PlanID = "plan-2"
response := json.RawMessage(`{"plan_id": "plan-2"}`)
if err := store.CompleteReplan(ctx, sessionID, "rq-1", "", response, completed); err != nil {
t.Fatalf("CompleteReplan: %v", err)
}
lease, err = store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-1", "", future)
if err != nil {
t.Fatalf("BeginReplan completed: %v", err)
}
if lease.State != playback.ReplanLeaseCompletedV3 {
t.Fatalf("BeginReplan completed state = %q, want completed", lease.State)
}
var storedResponse, wantResponse any
if err := json.Unmarshal(lease.Response, &storedResponse); err != nil {
t.Fatalf("unmarshal replayed response: %v", err)
}
if err := json.Unmarshal(response, &wantResponse); err != nil {
t.Fatalf("unmarshal expected response: %v", err)
}
if !bytes.Equal(mustJSON(t, storedResponse), mustJSON(t, wantResponse)) {
t.Fatalf("replayed response = %s, want %s", lease.Response, response)
}
// Expired lease whose base revision no longer matches the retry.
past := time.Now().Add(-time.Minute)
if _, err := store.BeginReplan(ctx, sessionID, "rq-stale", "rq-digest-stale", "base-x", past); err != nil {
t.Fatalf("BeginReplan seed stale lease: %v", err)
}
if _, err := store.BeginReplan(ctx, sessionID, "rq-stale", "rq-digest-stale", "base-y", future); !errors.Is(err, playback.ErrStaleReplanLeaseV3) {
t.Fatalf("expired lease with stale base: got %v, want ErrStaleReplanLeaseV3", err)
}
// Expired lease with a matching base is re-owned.
if _, err := store.BeginReplan(ctx, sessionID, "rq-retry", "rq-digest-retry", "rq-1", past); err != nil {
t.Fatalf("BeginReplan seed expired lease: %v", err)
}
lease, err = store.BeginReplan(ctx, sessionID, "rq-retry", "rq-digest-retry", "rq-1", future)
if err != nil {
t.Fatalf("BeginReplan re-own expired lease: %v", err)
}
if lease.State != playback.ReplanLeaseOwnedV3 {
t.Fatalf("re-owned lease state = %q, want owned", lease.State)
}
})
t.Run("CompleteReplan", func(t *testing.T) {
sessionID := uuid.NewString()
attemptID := "att-complete-" + sessionID
if err := store.SaveAttempt(ctx, f.attemptRecord(sessionID, attemptID, "digest-complete")); err != nil {
t.Fatalf("SaveAttempt: %v", err)
}
future := time.Now().Add(time.Minute)
if _, err := store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-1", "", future); err != nil {
t.Fatalf("BeginReplan: %v", err)
}
updated := f.attemptRecord(sessionID, attemptID, "digest-complete")
updated.EffectiveMediaFileID = f.altFileID
updated.CurrentPlanID = "plan-2"
updated.CurrentReplanRequestID = "rq-1"
updated.CurrentPlan.PlanID = "plan-2"
updated.CurrentPlan.EffectiveMediaFileID = f.altFileID
updated.CurrentPlan.DecisionReason = "transcode_fallback"
updated.ExpiresAt = time.Now().Add(2 * time.Hour).UTC().Truncate(time.Microsecond)
response := json.RawMessage(`{"plan_id": "plan-2", "status": "replanned"}`)
if err := store.CompleteReplan(ctx, sessionID, "rq-1", "", response, updated); err != nil {
t.Fatalf("CompleteReplan happy path: %v", err)
}
got, err := store.GetAttempt(ctx, sessionID)
if err != nil {
t.Fatalf("GetAttempt after replan: %v", err)
}
if got.CurrentReplanRequestID != "rq-1" {
t.Fatalf("current_replan_request_id = %q, want rq-1", got.CurrentReplanRequestID)
}
if got.EffectiveMediaFileID != f.altFileID {
t.Fatalf("effective_media_file_id = %d, want %d", got.EffectiveMediaFileID, f.altFileID)
}
if got.CurrentPlanID != "plan-2" || got.CurrentPlan.PlanID != "plan-2" || got.CurrentPlan.DecisionReason != "transcode_fallback" {
t.Fatalf("plan did not round-trip through replan: %+v", got.CurrentPlan)
}
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) {
t.Fatalf("plan JSON mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan))
}
// The migration's sync trigger must not fight the in-transaction CAS:
// the raw column must equal the new request ID, with no extra rewrite.
var rawRevision, replanState string
if err := f.pool.QueryRow(ctx, `
SELECT a.current_replan_request_id, r.state
FROM playback_v3_attempts a
JOIN playback_v3_replans r ON r.session_id = a.session_id AND r.replan_request_id = $2
WHERE a.session_id = $1::uuid`, sessionID, "rq-1").Scan(&rawRevision, &replanState); err != nil {
t.Fatalf("inspect attempt/replan rows: %v", err)
}
if rawRevision != "rq-1" {
t.Fatalf("raw current_replan_request_id = %q, want rq-1", rawRevision)
}
if replanState != "completed" {
t.Fatalf("replan state = %q, want completed", replanState)
}
// A second replan whose base does not match the current revision must
// lose the compare-and-swap.
if _, err := store.BeginReplan(ctx, sessionID, "rq-2", "rq-digest-2", "rq-1", future); err != nil {
t.Fatalf("BeginReplan second: %v", err)
}
stale := updated
stale.CurrentReplanRequestID = "rq-2"
if err := store.CompleteReplan(ctx, sessionID, "rq-2", "wrong-base", response, stale); !errors.Is(err, playback.ErrReplanSupersededV3) {
t.Fatalf("CompleteReplan wrong base: got %v, want ErrReplanSupersededV3", err)
}
// Unknown session.
if err := store.CompleteReplan(ctx, uuid.NewString(), "rq-1", "", response, updated); !errors.Is(err, playback.ErrSessionNotFound) {
t.Fatalf("CompleteReplan missing session: got %v, want ErrSessionNotFound", err)
}
})
t.Run("CleanupExpired", func(t *testing.T) {
sessionID := uuid.NewString()
attemptID := "att-cleanup-" + sessionID
if err := store.SaveAttempt(ctx, f.attemptRecord(sessionID, attemptID, "digest-cleanup")); err != nil {
t.Fatalf("SaveAttempt: %v", err)
}
if _, err := store.BeginReplan(ctx, sessionID, "rq-1", "rq-digest-1", "", time.Now().Add(time.Minute)); err != nil {
t.Fatalf("BeginReplan: %v", err)
}
// A survivor attempt that must not be swept.
keepSession := uuid.NewString()
keepAttempt := "att-keep-" + keepSession
if err := store.SaveAttempt(ctx, f.attemptRecord(keepSession, keepAttempt, "digest-keep")); err != nil {
t.Fatalf("SaveAttempt survivor: %v", err)
}
event := func(attempt string) playback.RouteEventRecordV3 {
return playback.RouteEventRecordV3{
RouteEventV3: playback.RouteEventV3{
ProtocolVersion: 3,
PlaybackAttemptID: attempt,
SessionID: sessionID,
Event: playback.RouteEventNamesV3()[0],
Diagnostics: map[string]string{},
},
UserID: f.userID,
ProfileID: "profile-1",
}
}
if err := store.RecordRouteEvent(ctx, event("att-cleanup-old")); err != nil {
t.Fatalf("RecordRouteEvent old: %v", err)
}
if err := store.RecordRouteEvent(ctx, event("att-cleanup-recent")); err != nil {
t.Fatalf("RecordRouteEvent recent: %v", err)
}
if _, err := f.pool.Exec(ctx, `
UPDATE playback_route_events SET received_at = NOW() - INTERVAL '31 days'
WHERE playback_attempt_id = 'att-cleanup-old'`); err != nil {
t.Fatalf("age route event: %v", err)
}
f.expireAttempt(t, attemptID)
removed, err := store.CleanupExpired(ctx, time.Now())
if err != nil {
t.Fatalf("CleanupExpired: %v", err)
}
if removed < 1 {
t.Fatalf("CleanupExpired removed %d attempts, want at least 1", removed)
}
var attempts, replans, oldEvents, recentEvents int
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM playback_v3_attempts WHERE playback_attempt_id = $1`, attemptID).Scan(&attempts); err != nil {
t.Fatalf("count attempts: %v", err)
}
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM playback_v3_replans WHERE session_id = $1::uuid`, sessionID).Scan(&replans); err != nil {
t.Fatalf("count replans: %v", err)
}
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM playback_route_events WHERE playback_attempt_id = 'att-cleanup-old'`).Scan(&oldEvents); err != nil {
t.Fatalf("count old events: %v", err)
}
if err := f.pool.QueryRow(ctx, `SELECT COUNT(*) FROM playback_route_events WHERE playback_attempt_id = 'att-cleanup-recent'`).Scan(&recentEvents); err != nil {
t.Fatalf("count recent events: %v", err)
}
if attempts != 0 {
t.Fatalf("expired attempt survived cleanup")
}
if replans != 0 {
t.Fatalf("replans did not cascade with the expired attempt")
}
if oldEvents != 0 {
t.Fatalf("31-day-old route event survived cleanup")
}
if recentEvents != 1 {
t.Fatalf("recent route event count = %d, want 1", recentEvents)
}
if _, err := store.GetAttempt(ctx, keepSession); err != nil {
t.Fatalf("unexpired attempt was swept: %v", err)
}
})
t.Run("AcquireSessionLock", func(t *testing.T) {
sessionID := uuid.NewString()
release1, err := store.AcquireSessionLock(ctx, sessionID)
if err != nil {
t.Fatalf("first AcquireSessionLock: %v", err)
}
// A different session must not be serialized behind the first lock.
otherCtx, cancelOther := context.WithTimeout(ctx, 5*time.Second)
defer cancelOther()
releaseOther, err := store.AcquireSessionLock(otherCtx, uuid.NewString())
if err != nil {
t.Fatalf("different-session AcquireSessionLock blocked: %v", err)
}
releaseOther()
// A second acquire on the same session must block until release.
type lockResult struct {
release func()
err error
}
acquired := make(chan lockResult, 1)
go func() {
release2, err := store.AcquireSessionLock(ctx, sessionID)
acquired <- lockResult{release: release2, err: err}
}()
select {
case result := <-acquired:
if result.err == nil {
result.release()
}
t.Fatalf("second lock acquired while first was held (err=%v)", result.err)
case <-time.After(300 * time.Millisecond):
// Still blocked, as required.
}
release1()
select {
case result := <-acquired:
if result.err != nil {
t.Fatalf("second AcquireSessionLock after release: %v", result.err)
}
result.release()
case <-time.After(5 * time.Second):
t.Fatal("second lock never acquired after first release")
}
// Releasing twice is safe (sync.Once) and the lock is free again.
release1()
release3, err := store.AcquireSessionLock(ctx, sessionID)
if err != nil {
t.Fatalf("reacquire after release: %v", err)
}
release3()
})
}
+269
View File
@@ -0,0 +1,269 @@
package playback
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
)
var ErrIdempotencyKeyReusedV3 = errors.New("idempotency key reused")
var ErrPlaybackAttemptExistsV3 = errors.New("playback attempt already exists")
var ErrStaleReplanLeaseV3 = errors.New("stale replan lease")
// ErrReplanSupersededV3 means a CompleteReplan lost the revision compare: a
// newer replan already moved the attempt past the caller's base revision.
var ErrReplanSupersededV3 = errors.New("replan superseded")
type AttemptRecordV3 struct {
PlaybackAttemptID string
SessionID string
UserID int
ProfileID string
RequestedMediaFileID int
EffectiveMediaFileID int
CurrentPlanID string
CurrentReplanRequestID string
CurrentPlan PlanV3
NormalizedRequest StartRequestV3
// RequestDigest fingerprints the normalized start request so an attempt-ID
// reused with different input is a detectable idempotency violation rather
// than a silent replay of the old plan.
RequestDigest string
ExpiresAt time.Time
}
// AttemptIdentityV3 carries only the ownership columns of an attempt so
// per-event authorization checks avoid decoding the plan and request JSONB.
type AttemptIdentityV3 struct {
PlaybackAttemptID string
SessionID string
UserID int
ProfileID string
}
type RouteEventRecordV3 struct {
RouteEventV3
UserID int
ProfileID string
ClientName string
ClientVersion string
ClientModel string
}
type ReplanLeaseStateV3 string
const (
ReplanLeaseOwnedV3 ReplanLeaseStateV3 = "owned"
ReplanLeaseInFlightV3 ReplanLeaseStateV3 = "in_flight"
ReplanLeaseCompletedV3 ReplanLeaseStateV3 = "completed"
)
type ReplanLeaseV3 struct {
State ReplanLeaseStateV3
Response json.RawMessage
}
type PlanStoreV3 interface {
AcquireSessionLock(context.Context, string) (func(), error)
SaveAttempt(context.Context, AttemptRecordV3) error
GetAttempt(context.Context, string) (*AttemptRecordV3, error)
GetAttemptByPlaybackAttemptID(context.Context, string) (*AttemptRecordV3, error)
GetAttemptIdentity(context.Context, string) (*AttemptIdentityV3, error)
GetAttemptIdentityByPlaybackAttemptID(context.Context, string) (*AttemptIdentityV3, error)
BeginReplan(context.Context, string, string, string, string, time.Time) (ReplanLeaseV3, error)
// CompleteReplan commits a replan atomically; the attempt row is only
// updated while its current_replan_request_id still equals the caller's
// base revision, otherwise ErrReplanSupersededV3 is returned.
CompleteReplan(ctx context.Context, sessionID, requestID, baseReplanRequestID string, response json.RawMessage, record AttemptRecordV3) error
RecordRouteEvent(context.Context, RouteEventRecordV3) error
CleanupExpired(context.Context, time.Time) (int64, error)
}
type memoryReplanV3 struct {
digest string
base string
lease time.Time
completed bool
response json.RawMessage
}
type MemoryPlanStoreV3 struct {
mu sync.Mutex
attempts map[string]AttemptRecordV3
replans map[string]memoryReplanV3
events []RouteEventRecordV3
}
func NewMemoryPlanStoreV3() *MemoryPlanStoreV3 {
return &MemoryPlanStoreV3{attempts: make(map[string]AttemptRecordV3), replans: make(map[string]memoryReplanV3)}
}
// AcquireSessionLock is deliberately a no-op. The store lock exists to
// serialize replans across processes sharing one PostgreSQL database; the
// memory store only ever backs a single-process, DB-less deployment, where
// the handler's own per-session replan mutex already provides the same
// serialization before this lock is taken.
func (s *MemoryPlanStoreV3) AcquireSessionLock(context.Context, string) (func(), error) {
return func() {}, nil
}
func (s *MemoryPlanStoreV3) SaveAttempt(_ context.Context, record AttemptRecordV3) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
// Expired rows are replaceable, mirroring the Postgres pre-delete: they
// linger until the hourly cleanup and must not wedge a legitimate retry.
for sessionID, existing := range s.attempts {
if existing.ExpiresAt.After(now) {
continue
}
if existing.PlaybackAttemptID == record.PlaybackAttemptID || sessionID == record.SessionID {
s.deleteAttemptLocked(sessionID)
}
}
for sessionID, existing := range s.attempts {
if existing.PlaybackAttemptID != record.PlaybackAttemptID && sessionID != record.SessionID {
continue
}
if existing.PlaybackAttemptID == record.PlaybackAttemptID &&
existing.RequestDigest != "" && record.RequestDigest != "" && existing.RequestDigest != record.RequestDigest {
return ErrIdempotencyKeyReusedV3
}
return ErrPlaybackAttemptExistsV3
}
s.attempts[record.SessionID] = record
return nil
}
// ReplaceAttempt overwrites a session's attempt record unconditionally. It is
// not part of PlanStoreV3: durable stores treat attempts as insert-once and
// replan-updated, so only in-memory test setups may rewrite one in place.
func (s *MemoryPlanStoreV3) ReplaceAttempt(_ context.Context, record AttemptRecordV3) {
s.mu.Lock()
defer s.mu.Unlock()
s.attempts[record.SessionID] = record
}
func (s *MemoryPlanStoreV3) deleteAttemptLocked(sessionID string) {
delete(s.attempts, sessionID)
for key := range s.replans {
if strings.HasPrefix(key, sessionID+":") {
delete(s.replans, key)
}
}
}
func (s *MemoryPlanStoreV3) GetAttemptByPlaybackAttemptID(_ context.Context, attemptID string) (*AttemptRecordV3, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, record := range s.attempts {
if record.PlaybackAttemptID == attemptID && record.ExpiresAt.After(time.Now()) {
copy := record
return &copy, nil
}
}
return nil, ErrSessionNotFound
}
func (s *MemoryPlanStoreV3) GetAttempt(_ context.Context, sessionID string) (*AttemptRecordV3, error) {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.attempts[sessionID]
if !ok || !record.ExpiresAt.After(time.Now()) {
return nil, ErrSessionNotFound
}
copy := record
return &copy, nil
}
func (s *MemoryPlanStoreV3) BeginReplan(_ context.Context, sessionID, requestID, digest, baseReplanRequestID string, leaseUntil time.Time) (ReplanLeaseV3, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := sessionID + ":" + requestID
existing, ok := s.replans[key]
if !ok {
s.replans[key] = memoryReplanV3{digest: digest, base: baseReplanRequestID, lease: leaseUntil}
return ReplanLeaseV3{State: ReplanLeaseOwnedV3}, nil
}
if existing.digest != digest {
return ReplanLeaseV3{}, ErrIdempotencyKeyReusedV3
}
if existing.completed {
return ReplanLeaseV3{State: ReplanLeaseCompletedV3, Response: append(json.RawMessage(nil), existing.response...)}, nil
}
if time.Now().Before(existing.lease) {
return ReplanLeaseV3{State: ReplanLeaseInFlightV3}, nil
}
if existing.base != baseReplanRequestID {
return ReplanLeaseV3{}, ErrStaleReplanLeaseV3
}
existing.lease = leaseUntil
s.replans[key] = existing
return ReplanLeaseV3{State: ReplanLeaseOwnedV3}, nil
}
func (s *MemoryPlanStoreV3) CompleteReplan(_ context.Context, sessionID, requestID, baseReplanRequestID string, response json.RawMessage, record AttemptRecordV3) error {
s.mu.Lock()
defer s.mu.Unlock()
existing, ok := s.attempts[sessionID]
if !ok {
return ErrSessionNotFound
}
if existing.CurrentReplanRequestID != baseReplanRequestID {
return ErrReplanSupersededV3
}
key := sessionID + ":" + requestID
entry, ok := s.replans[key]
if !ok {
return ErrSessionNotFound
}
entry.completed = true
entry.response = append(json.RawMessage(nil), response...)
s.replans[key] = entry
s.attempts[sessionID] = record
return nil
}
func (s *MemoryPlanStoreV3) GetAttemptIdentity(ctx context.Context, sessionID string) (*AttemptIdentityV3, error) {
record, err := s.GetAttempt(ctx, sessionID)
if err != nil {
return nil, err
}
return &AttemptIdentityV3{PlaybackAttemptID: record.PlaybackAttemptID, SessionID: record.SessionID, UserID: record.UserID, ProfileID: record.ProfileID}, nil
}
func (s *MemoryPlanStoreV3) GetAttemptIdentityByPlaybackAttemptID(ctx context.Context, attemptID string) (*AttemptIdentityV3, error) {
record, err := s.GetAttemptByPlaybackAttemptID(ctx, attemptID)
if err != nil {
return nil, err
}
return &AttemptIdentityV3{PlaybackAttemptID: record.PlaybackAttemptID, SessionID: record.SessionID, UserID: record.UserID, ProfileID: record.ProfileID}, nil
}
func (s *MemoryPlanStoreV3) RecordRouteEvent(_ context.Context, record RouteEventRecordV3) error {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, record)
return nil
}
func (s *MemoryPlanStoreV3) CleanupExpired(_ context.Context, now time.Time) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var count int64
for sessionID, record := range s.attempts {
if !record.ExpiresAt.After(now) {
delete(s.attempts, sessionID)
for key := range s.replans {
if strings.HasPrefix(key, sessionID+":") {
delete(s.replans, key)
}
}
count++
}
}
return count, nil
}
+824
View File
@@ -0,0 +1,824 @@
package playback
import (
"errors"
"fmt"
"math"
"regexp"
"slices"
"strings"
"time"
)
const (
ProtocolV3 = 3
FeaturePlaybackPlanV3 = "playback_plan_v3"
FeatureMedia3Only = "media3_only"
FeatureDetailedDecodeV3 = "detailed_decode_capabilities"
FeatureLayoutPassthrough = "layout_aware_passthrough"
FeatureClientVideoTransforms = "client_video_transformations_v1"
FeatureRouteDiagnostics = "playback_route_diagnostics"
FeatureDeviceQuirksV3 = "device_quirks_v1"
FeatureSeekReanchorV3 = "seek_reanchor_v1"
PlanRecipeVersionV3 = "v3.2"
ClientDV7ToDV81V3 = "client_dv7_to_dv81"
ClientDV7ToHDR10V3 = "client_dv7_to_hdr10"
ClientDVTransformVersionV3 = "1"
ClientDV8HDR10PlusSanitizerV3 = "client_dv8_hdr10plus_sanitizer_v1"
ClientPostResumeRecoveryV3 = "client_post_resume_video_recovery_v1"
ClientSurfaceRecoveryV3 = "client_surface_recovery_v1"
DeviceQuirkRegistryRevisionV3 = "2026-07-13.1"
)
type DecisionOutcomeV3 string
const (
OutcomePlayableV3 DecisionOutcomeV3 = "playable"
OutcomeAdaptationUnavailableV3 DecisionOutcomeV3 = "adaptation_unavailable"
)
type DeliveryV3 string
const (
DeliveryOriginalHTTPV3 DeliveryV3 = "original_http"
DeliveryRemuxHLSV3 DeliveryV3 = "server_remux_hls"
DeliveryRemuxProgressiveV3 DeliveryV3 = "server_remux_progressive"
DeliveryTranscodeHLSV3 DeliveryV3 = "server_transcode_hls"
)
func (d DeliveryV3) KotlinName() string {
switch d {
case DeliveryOriginalHTTPV3:
return "ORIGINAL_HTTP"
case DeliveryRemuxHLSV3:
return "SERVER_REMUX_HLS"
case DeliveryRemuxProgressiveV3:
return "SERVER_REMUX_PROGRESSIVE"
case DeliveryTranscodeHLSV3:
return "SERVER_TRANSCODE_HLS"
default:
return strings.ToUpper(string(d))
}
}
type EngineV3 string
const (
EngineMedia3DirectV3 EngineV3 = "media3_direct"
EngineMedia3ProgressiveRemuxV3 EngineV3 = "media3_progressive_remux"
EngineMedia3HLSV3 EngineV3 = "media3_hls"
)
type StreamProtocolV3 string
const (
StreamHTTPProgressiveV3 StreamProtocolV3 = "http_progressive"
StreamHLSV3 StreamProtocolV3 = "hls"
)
func (p StreamProtocolV3) KotlinName() string {
switch p {
case StreamHTTPProgressiveV3:
return "HTTP_PROGRESSIVE"
case StreamHLSV3:
return "HLS"
default:
return strings.ToUpper(string(p))
}
}
type HeaderRefreshModeV3 string
const (
HeaderRefreshNoneV3 HeaderRefreshModeV3 = "none"
HeaderRefreshSessionV3 HeaderRefreshModeV3 = "session"
)
type SubtitleModeV3 string
const (
SubtitleOffV3 SubtitleModeV3 = "off"
SubtitleRenderV3 SubtitleModeV3 = "render"
SubtitleConvertV3 SubtitleModeV3 = "convert"
SubtitleBurnInV3 SubtitleModeV3 = "burn_in"
)
func (m SubtitleModeV3) KotlinName() string {
switch m {
case SubtitleOffV3:
return "OFF"
case SubtitleRenderV3:
return "RENDER"
case SubtitleConvertV3:
return "CONVERT"
case SubtitleBurnInV3:
return "BURN_IN"
default:
return strings.ToUpper(string(m))
}
}
type SubtitleFidelityV3 string
const (
SubtitleFidelityPreserveV3 SubtitleFidelityV3 = "preserve"
SubtitleFidelityCompatibleV3 SubtitleFidelityV3 = "compatible"
)
type EnhancementLayerV3 string
const (
EnhancementNoneV3 EnhancementLayerV3 = "none"
EnhancementMELV3 EnhancementLayerV3 = "mel"
EnhancementFELV3 EnhancementLayerV3 = "fel"
EnhancementUnknownV3 EnhancementLayerV3 = "unknown"
)
type HDRCapabilitiesV3 struct {
HDR10 bool `json:"hdr10"`
HDR10Plus bool `json:"hdr10_plus"`
HLG bool `json:"hlg"`
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
}
type AudioPassthroughV3 struct {
PassthroughCodecs []string `json:"passthrough_codecs"`
SpatializerEnabled bool `json:"spatializer_enabled"`
MaxChannels int `json:"max_channels"`
Entries []AudioPassthroughEntryV3 `json:"entries,omitempty"`
}
type AudioPassthroughEntryV3 struct {
Codec string `json:"codec"`
ChannelCounts []int `json:"channel_counts,omitempty"`
Layouts []string `json:"layouts,omitempty"`
}
type VideoDecodeCapabilityV3 struct {
Codec string `json:"codec"`
DecoderName string `json:"decoder_name,omitempty"`
Profiles []string `json:"profiles,omitempty"`
Levels []int `json:"levels,omitempty"`
BitDepths []int `json:"bit_depths,omitempty"`
MaxWidth int `json:"max_width,omitempty"`
MaxHeight int `json:"max_height,omitempty"`
MaxFrameRate float64 `json:"max_frame_rate,omitempty"`
MaxBitrateKbps int `json:"max_bitrate_kbps,omitempty"`
Hardware bool `json:"hardware"`
}
type ClientCodecCapabilitiesV3 struct {
CodecsVideo []string `json:"codecs_video"`
CodecsVideoHardware []string `json:"codecs_video_hardware"`
CodecsAudio []string `json:"codecs_audio"`
Containers []string `json:"containers"`
MaxResolution string `json:"max_resolution,omitempty"`
HDR bool `json:"hdr"`
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
AudioPassthrough *AudioPassthroughV3 `json:"audio_passthrough,omitempty"`
VideoDecode []VideoDecodeCapabilityV3 `json:"video_decode,omitempty"`
}
type DeviceContextV3 struct {
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
Brand string `json:"brand,omitempty"`
Device string `json:"device,omitempty"`
Product string `json:"product,omitempty"`
SoCManufacturer string `json:"soc_manufacturer,omitempty"`
SoCModel string `json:"soc_model,omitempty"`
BuildID string `json:"build_id,omitempty"`
BuildDisplay string `json:"build_display,omitempty"`
SecurityPatch string `json:"security_patch,omitempty"`
SDKInt int `json:"sdk_int,omitempty"`
ABIs []string `json:"abis,omitempty"`
}
type OutputContextV3 struct {
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
AudioPassthrough *AudioPassthroughV3 `json:"audio_passthrough,omitempty"`
CurrentSink string `json:"current_sink,omitempty"`
SinkType string `json:"sink_type,omitempty"`
OutputRouteGeneration int64 `json:"output_route_generation"`
}
type EngineSubtitleCapabilitiesV3 struct {
EmbeddedText bool `json:"embedded_text"`
SidecarText bool `json:"sidecar_text"`
ASSStyling bool `json:"ass_styling"`
EmbeddedBitmap bool `json:"embedded_bitmap"`
SidecarBitmap bool `json:"sidecar_bitmap"`
FontAttachments bool `json:"font_attachments"`
}
type EngineCapabilityV3 struct {
Enabled bool `json:"enabled"`
SupportedOnDevice bool `json:"supported_on_device"`
FailureReason string `json:"failure_reason,omitempty"`
Containers []string `json:"containers"`
VideoCodecs []string `json:"video_codecs"`
AudioDecodeCodecs []string `json:"audio_decode_codecs"`
AudioPassthroughCodecs []string `json:"audio_passthrough_codecs"`
MaxChannels *int `json:"max_channels,omitempty"`
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
Subtitles EngineSubtitleCapabilitiesV3 `json:"subtitles"`
Features []string `json:"features"`
AuthHeaderRefresh bool `json:"auth_header_refresh"`
ValidatedClaims []string `json:"validated_claims"`
Transformations []TransformationV3 `json:"transformations"`
}
type ClientPlaybackContextV3 struct {
ProtocolVersion int `json:"protocol_version"`
Features []string `json:"features"`
Platform string `json:"platform"`
FormFactor string `json:"form_factor"`
AppVersion string `json:"app_version"`
Device DeviceContextV3 `json:"device"`
Output OutputContextV3 `json:"output"`
Engines map[string]EngineCapabilityV3 `json:"engines"`
}
type StartRequestV3 struct {
ProtocolVersion int `json:"protocol_version"`
ClientFeatures []string `json:"client_features"`
FileID int `json:"file_id"`
ProfileID string `json:"profile_id"`
PlaybackAttemptID string `json:"playback_attempt_id"`
QualityPreference string `json:"quality_preference"`
SubtitleFidelityPreference SubtitleFidelityV3 `json:"subtitle_fidelity_preference"`
StartPosition *float64 `json:"start_position,omitempty"`
AudioTrackID string `json:"audio_track_id,omitempty"`
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
SubtitleTrackID string `json:"subtitle_track_id,omitempty"`
SubtitleTrackIndex *int `json:"subtitle_track_index,omitempty"`
OutputRouteGeneration int64 `json:"output_route_generation"`
Metered bool `json:"metered"`
BandwidthEstimateKbps *int `json:"bandwidth_estimate_kbps,omitempty"`
BandwidthCapKbps *int `json:"bandwidth_cap_kbps,omitempty"`
Capabilities ClientCodecCapabilitiesV3 `json:"client_capabilities"`
ClientPlaybackContext ClientPlaybackContextV3 `json:"client_playback_context"`
}
type TrackIdentityV3 struct {
ID string `json:"id"`
Index *int `json:"index,omitempty"`
}
type SelectedTracksV3 struct {
Audio *TrackIdentityV3 `json:"audio,omitempty"`
Subtitle *TrackIdentityV3 `json:"subtitle,omitempty"`
}
type FailureV3 struct {
Classification string `json:"classification"`
Message string `json:"message,omitempty"`
DecoderName string `json:"decoder_name,omitempty"`
}
type ReplanOperationV3 string
const (
ReplanOperationFailureRecoveryV3 ReplanOperationV3 = "failure_recovery"
ReplanOperationSeekReanchorV3 ReplanOperationV3 = "seek_reanchor"
ReplanOperationSeekFailureRecoveryV3 ReplanOperationV3 = "seek_failure_recovery"
)
type ReplanRequestV3 struct {
ProtocolVersion int `json:"protocol_version"`
Operation ReplanOperationV3 `json:"operation,omitempty"`
PlaybackAttemptID string `json:"playback_attempt_id"`
ReplanRequestID string `json:"replan_request_id"`
FailedPlanID string `json:"failed_plan_id"`
PlanAttemptID string `json:"plan_attempt_id"`
PlanAttemptKey string `json:"plan_attempt_key"`
AttemptedPlanKeys []string `json:"attempted_plan_keys"`
AttemptCount int `json:"attempt_count"`
QualityPreference string `json:"quality_preference"`
PositionSeconds float64 `json:"position_seconds"`
OutputRouteGeneration int64 `json:"output_route_generation"`
Metered bool `json:"metered"`
BandwidthEstimateKbps *int `json:"bandwidth_estimate_kbps,omitempty"`
BandwidthCapKbps *int `json:"bandwidth_cap_kbps,omitempty"`
SelectedTracks SelectedTracksV3 `json:"selected_tracks"`
Failure FailureV3 `json:"failure"`
Capabilities ClientCodecCapabilitiesV3 `json:"client_capabilities"`
ClientPlaybackContext ClientPlaybackContextV3 `json:"client_playback_context"`
}
const (
RouteEventPlanSelectedV3 = "plan_selected"
RouteEventPlanInvalidatedV3 = "plan_invalidated"
RouteEventPlanFailedV3 = "plan_failed"
RouteEventFirstFrameV3 = "first_frame"
RouteEventTerminalV3 = "terminal"
RouteEventStoppedV3 = "stopped"
RouteEventRuntimeCorrectionAppliedV3 = "runtime_correction_applied"
RouteEventRuntimeCorrectionSucceededV3 = "runtime_correction_succeeded"
RouteEventRuntimeCorrectionFailedV3 = "runtime_correction_failed"
RouteEventSeekReanchorRequestedV3 = "seek_reanchor_requested"
RouteEventSeekReanchoredV3 = "seek_reanchored"
)
var routeEventNamesV3 = []string{
RouteEventPlanSelectedV3,
RouteEventPlanInvalidatedV3,
RouteEventPlanFailedV3,
RouteEventFirstFrameV3,
RouteEventTerminalV3,
RouteEventStoppedV3,
RouteEventRuntimeCorrectionAppliedV3,
RouteEventRuntimeCorrectionSucceededV3,
RouteEventRuntimeCorrectionFailedV3,
RouteEventSeekReanchorRequestedV3,
RouteEventSeekReanchoredV3,
}
// RouteEventNamesV3 returns the complete protocol-v3 telemetry event contract.
func RouteEventNamesV3() []string {
return append([]string(nil), routeEventNamesV3...)
}
// ValidRouteEventNameV3 reports whether name is part of the protocol-v3
// telemetry contract shared by handlers, persistence, and clients.
func ValidRouteEventNameV3(name string) bool {
return slices.Contains(routeEventNamesV3, name)
}
// EffectiveOperation keeps clients which predate the explicit operation field
// on the ordinary failure-recovery path. Seek operations are deliberately
// opt-in because both pin the current media version and user intent; an exact
// reanchor also preserves the current route instead of walking the ladder.
func (r ReplanRequestV3) EffectiveOperation() ReplanOperationV3 {
if r.Operation == "" {
return ReplanOperationFailureRecoveryV3
}
return r.Operation
}
type RouteEventV3 struct {
ProtocolVersion int `json:"protocol_version"`
PlaybackAttemptID string `json:"playback_attempt_id"`
SessionID string `json:"session_id,omitempty"`
PlanID string `json:"plan_id,omitempty"`
PlanAttemptID string `json:"plan_attempt_id,omitempty"`
PlanAttemptKey string `json:"plan_attempt_key,omitempty"`
Event string `json:"event"`
FailureClassification string `json:"failure_classification,omitempty"`
FallbackReason string `json:"fallback_reason,omitempty"`
AppliedQuirkIDs []string `json:"applied_quirk_ids,omitempty"`
QuirkRegistryRevision string `json:"quirk_registry_revision,omitempty"`
OutputRouteGeneration int64 `json:"output_route_generation"`
Diagnostics map[string]string `json:"diagnostics"`
}
type StreamV3 struct {
URL string `json:"url"`
Protocol StreamProtocolV3 `json:"protocol"`
Container string `json:"container,omitempty"`
MIMEType string `json:"mime_type,omitempty"`
Headers map[string]string `json:"headers"`
HeaderRefresh HeaderRefreshModeV3 `json:"header_refresh"`
HeaderRefreshURL string `json:"header_refresh_url,omitempty"`
}
type TimelineV3 struct {
SourceStartSeconds float64 `json:"source_start_seconds"`
StreamOriginSeconds float64 `json:"stream_origin_seconds"`
PlayerStartSeconds float64 `json:"player_start_seconds"`
TimelineOffsetSeconds float64 `json:"timeline_offset_seconds"`
SeekWindowStartSeconds *float64 `json:"seek_window_start_seconds,omitempty"`
SeekWindowEndSeconds *float64 `json:"seek_window_end_seconds,omitempty"`
CanSeekAnywhere bool `json:"can_seek_anywhere"`
SeekRestoration string `json:"seek_restoration"`
}
type EffectiveRecipeV3 struct {
VideoCodec string `json:"video_codec,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
FrameRate *float64 `json:"frame_rate,omitempty"`
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
DynamicRange string `json:"dynamic_range,omitempty"`
AudioChannels *int `json:"audio_channels,omitempty"`
AudioLayout string `json:"audio_layout,omitempty"`
}
type SourceDescriptorV3 struct {
MediaFileID int `json:"media_file_id"`
Container string `json:"container,omitempty"`
VideoCodec string `json:"video_codec,omitempty"`
VideoProfile string `json:"video_profile,omitempty"`
VideoLevel int `json:"video_level,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FrameRate float64 `json:"frame_rate,omitempty"`
BitrateKbps int `json:"bitrate_kbps,omitempty"`
DynamicRange string `json:"dynamic_range,omitempty"`
HDR10Plus bool `json:"hdr10_plus"`
DVProfile int `json:"dolby_vision_profile,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVEnhancementLayer EnhancementLayerV3 `json:"dv_enhancement_layer"`
AudioCodec string `json:"audio_codec,omitempty"`
AudioChannels int `json:"audio_channels,omitempty"`
AudioLayout string `json:"audio_layout,omitempty"`
}
type VideoClaimsV3 struct {
HDR10 bool `json:"hdr10"`
HDR10Plus bool `json:"hdr10_plus"`
HLG bool `json:"hlg"`
DolbyVision bool `json:"dolby_vision"`
DolbyVisionReason string `json:"dolby_vision_reason,omitempty"`
}
type AudioClaimsV3 struct {
Codec string `json:"codec,omitempty"`
Passthrough bool `json:"passthrough"`
AtmosPreserved bool `json:"atmos_preserved"`
DTSVariant string `json:"dts_variant,omitempty"`
Reason string `json:"reason,omitempty"`
}
type SubtitleClaimsV3 struct {
ASSStylingPreserved bool `json:"ass_styling_preserved"`
BitmapOverlay bool `json:"bitmap_overlay"`
BitmapSidecar bool `json:"bitmap_sidecar"`
Reason string `json:"reason,omitempty"`
}
type ValidationClaimsV3 struct {
Video VideoClaimsV3 `json:"video"`
Audio AudioClaimsV3 `json:"audio"`
Subtitles SubtitleClaimsV3 `json:"subtitles"`
}
type SubtitleArtifactV3 struct {
URL string `json:"url"`
MIMEType string `json:"mime_type"`
Format string `json:"format"`
TimingOriginSeconds float64 `json:"timing_origin_seconds"`
}
type SubtitleDecisionV3 struct {
Mode SubtitleModeV3 `json:"mode"`
TrackID string `json:"track_id,omitempty"`
Artifact *SubtitleArtifactV3 `json:"artifact,omitempty"`
}
type TransformationV3 struct {
Name string `json:"name"`
Executor string `json:"executor"`
RecipeVersion string `json:"recipe_version"`
ValidatedClaims []string `json:"validated_claims"`
}
type AppliedQuirkV3 struct {
ID string `json:"id"`
RegistryRevision string `json:"registry_revision"`
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
}
type DegradationWarningV3 struct {
Code string `json:"code"`
Message string `json:"message"`
}
type PlanV3 struct {
ProtocolVersion int `json:"protocol_version"`
PlanID string `json:"plan_id"`
SessionID string `json:"session_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Delivery DeliveryV3 `json:"delivery"`
Engine EngineV3 `json:"engine"`
Stream StreamV3 `json:"stream"`
Timeline TimelineV3 `json:"timeline"`
SelectedTracks SelectedTracksV3 `json:"selected_tracks"`
EffectiveRecipe EffectiveRecipeV3 `json:"effective_recipe"`
Claims ValidationClaimsV3 `json:"claims"`
Subtitle SubtitleDecisionV3 `json:"subtitle"`
Transformations []TransformationV3 `json:"transformations"`
AppliedQuirks []AppliedQuirkV3 `json:"applied_quirks"`
RuntimeCorrections []string `json:"runtime_corrections"`
DegradationWarnings []DegradationWarningV3 `json:"degradation_warnings"`
DecisionReason string `json:"decision_reason"`
RequestedMediaFileID int `json:"requested_media_file_id"`
EffectiveMediaFileID int `json:"effective_media_file_id"`
Source SourceDescriptorV3 `json:"source"`
SubtitleFidelityPolicy string `json:"subtitle_fidelity_policy"`
}
type TerminalV3 struct {
Reason string `json:"reason"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type DecisionResponseV3 struct {
ProtocolVersion int `json:"protocol_version,omitempty"`
ServerFeatures []string `json:"server_features"`
Outcome DecisionOutcomeV3 `json:"outcome,omitempty"`
SessionID string `json:"session_id,omitempty"`
PlaybackPlan *PlanV3 `json:"playback_plan,omitempty"`
Terminal *TerminalV3 `json:"terminal,omitempty"`
}
type CapabilityResponseV3 struct {
Enabled bool `json:"enabled"`
ProtocolVersions []int `json:"protocol_versions"`
Features []string `json:"features"`
Deliveries []DeliveryV3 `json:"deliveries"`
Transformations []TransformationV3 `json:"transformations"`
Reason string `json:"reason,omitempty"`
}
var boundedIdentifierV3 = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`)
func (r *StartRequestV3) NormalizeAndValidate() ([]DegradationWarningV3, error) {
if r.ProtocolVersion != ProtocolV3 {
return nil, fmt.Errorf("protocol_version must be %d", ProtocolV3)
}
if r.FileID <= 0 || strings.TrimSpace(r.ProfileID) == "" {
return nil, errors.New("file_id and profile_id are required")
}
if !boundedIdentifierV3.MatchString(r.PlaybackAttemptID) {
return nil, errors.New("playback_attempt_id is invalid")
}
if r.OutputRouteGeneration < 0 || r.ClientPlaybackContext.Output.OutputRouteGeneration != r.OutputRouteGeneration {
return nil, errors.New("output_route_generation is invalid or inconsistent")
}
if r.ClientPlaybackContext.ProtocolVersion != ProtocolV3 {
return nil, errors.New("client_playback_context.protocol_version must be 3")
}
if r.StartPosition != nil && (!isFiniteV3(*r.StartPosition) || *r.StartPosition < 0 || *r.StartPosition > 31_536_000) {
return nil, errors.New("start_position is outside the supported range")
}
if err := validateOptionalBoundedIntV3(r.BandwidthEstimateKbps, 100, 1_000_000, "bandwidth_estimate_kbps"); err != nil {
return nil, err
}
if err := validateOptionalBoundedIntV3(r.BandwidthCapKbps, 100, 1_000_000, "bandwidth_cap_kbps"); err != nil {
return nil, err
}
if r.SubtitleFidelityPreference != SubtitleFidelityPreserveV3 && r.SubtitleFidelityPreference != SubtitleFidelityCompatibleV3 {
return nil, errors.New("subtitle_fidelity_preference is invalid")
}
if len(r.ClientFeatures) > 64 {
return nil, errors.New("client_features exceeds supported size")
}
for _, feature := range r.ClientFeatures {
if len(feature) > 128 {
return nil, errors.New("client feature exceeds supported size")
}
}
if err := validateCapabilitiesV3(&r.Capabilities, &r.ClientPlaybackContext, r.ClientFeatures); err != nil {
return nil, err
}
if err := validateTrackPairV3(r.FileID, "audio", r.AudioTrackID, r.AudioTrackIndex); err != nil {
return nil, err
}
if err := validateTrackPairV3(r.FileID, "subtitle", r.SubtitleTrackID, r.SubtitleTrackIndex); err != nil {
return nil, err
}
quality, changed := NormalizeQualityV3(r.QualityPreference)
r.QualityPreference = quality
if changed {
return []DegradationWarningV3{{Code: "quality_preference_normalized", Message: "Unknown quality preference was normalized to auto."}}, nil
}
return nil, nil
}
func NormalizeQualityV3(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto":
return "auto", false
case "original", "source", "max":
return "original", false
case "2160p", "4k", "uhd":
return "2160p", false
case "1080p", "fhd":
return "1080p", false
case "720p", "hd":
return "720p", false
case "480p", "sd":
return "480p", false
default:
return "auto", true
}
}
func (r ReplanRequestV3) Validate() error {
if r.ProtocolVersion != ProtocolV3 || !boundedIdentifierV3.MatchString(r.PlaybackAttemptID) || !boundedIdentifierV3.MatchString(r.ReplanRequestID) {
return errors.New("invalid replan identity")
}
if len(r.FailedPlanID) < 8 || len(r.FailedPlanID) > 128 || len(r.PlanAttemptID) < 8 || len(r.PlanAttemptID) > 128 || len(r.PlanAttemptKey) < 8 || len(r.PlanAttemptKey) > 128 || !strings.HasPrefix(r.PlanAttemptKey, "v3:") || r.AttemptCount < 1 || r.AttemptCount > 8 {
return errors.New("invalid replan attempt")
}
if r.OutputRouteGeneration < 0 || r.ClientPlaybackContext.Output.OutputRouteGeneration != r.OutputRouteGeneration {
return errors.New("invalid output route generation")
}
if len(r.AttemptedPlanKeys) > 16 || len(r.Failure.Classification) > 64 || len(r.Failure.Message) > 512 || len(r.Failure.DecoderName) > 128 || !isFiniteV3(r.PositionSeconds) || r.PositionSeconds < 0 || r.PositionSeconds > 31_536_000 {
return errors.New("replan bounds exceeded")
}
if err := validateOptionalBoundedIntV3(r.BandwidthEstimateKbps, 100, 1_000_000, "bandwidth_estimate_kbps"); err != nil {
return err
}
if err := validateOptionalBoundedIntV3(r.BandwidthCapKbps, 100, 1_000_000, "bandwidth_cap_kbps"); err != nil {
return err
}
if err := validateSelectedTrackIdentityV3("audio", r.SelectedTracks.Audio); err != nil {
return err
}
if err := validateSelectedTrackIdentityV3("subtitle", r.SelectedTracks.Subtitle); err != nil {
return err
}
switch r.EffectiveOperation() {
case ReplanOperationFailureRecoveryV3, ReplanOperationSeekFailureRecoveryV3:
if r.Failure.Classification == "" {
return errors.New("failure recovery requires a failure classification")
}
case ReplanOperationSeekReanchorV3:
// An exact seek reanchor is a timeline operation, not a failed recipe.
// Classification remains accepted for older callers but is not required
// and never selects seek semantics.
default:
return errors.New("invalid replan operation")
}
for _, key := range r.AttemptedPlanKeys {
if len(key) > 128 || !strings.HasPrefix(key, "v3:") {
return errors.New("invalid attempted plan key")
}
}
return validateCapabilitiesV3(&r.Capabilities, &r.ClientPlaybackContext, nil)
}
func validateSelectedTrackIdentityV3(kind string, track *TrackIdentityV3) error {
if track == nil {
return nil
}
if len(track.ID) > 128 {
return fmt.Errorf("%s track id exceeds supported size", kind)
}
if track.Index != nil && (*track.Index < 0 || *track.Index > 10_000) {
return fmt.Errorf("%s track index is invalid", kind)
}
return nil
}
// validateCapabilitiesV3 validates and normalizes the shared capability
// payload. topLevelFeatures carries the request's client_features when the
// request type has one (replans do not); feature checks accept either
// location, matching the planner's dual-source reads.
func validateCapabilitiesV3(c *ClientCodecCapabilitiesV3, ctx *ClientPlaybackContextV3, topLevelFeatures []string) error {
if len(c.CodecsVideo) > 64 || len(c.CodecsVideoHardware) > 64 || len(c.CodecsAudio) > 64 || len(c.Containers) > 64 || len(c.VideoDecode) > 64 || len(ctx.Features) > 64 || len(ctx.Engines) > 16 || len(ctx.Device.ABIs) > 16 || len(ctx.Platform) > 32 || len(ctx.FormFactor) > 32 || len(ctx.AppVersion) > 64 {
return errors.New("capability list exceeds supported size")
}
deviceValues := []string{
ctx.Device.Manufacturer, ctx.Device.Model, ctx.Device.Brand, ctx.Device.Device,
ctx.Device.Product, ctx.Device.SoCManufacturer, ctx.Device.SoCModel, ctx.Device.BuildID,
ctx.Device.BuildDisplay, ctx.Device.SecurityPatch, ctx.Output.CurrentSink, ctx.Output.SinkType,
}
for _, value := range deviceValues {
if len(value) > 128 {
return errors.New("device capability value exceeds supported size")
}
}
for _, values := range [][]string{c.CodecsVideo, c.CodecsVideoHardware, c.CodecsAudio, c.Containers, ctx.Features} {
for i := range values {
values[i] = strings.ToLower(strings.TrimSpace(values[i]))
if len(values[i]) > 128 {
return errors.New("capability value exceeds supported size")
}
}
}
for i := range c.VideoDecode {
c.VideoDecode[i].Codec = strings.ToLower(strings.TrimSpace(c.VideoDecode[i].Codec))
if c.VideoDecode[i].Codec == "" || len(c.VideoDecode[i].DecoderName) > 128 || c.VideoDecode[i].MaxWidth < 0 || c.VideoDecode[i].MaxHeight < 0 || c.VideoDecode[i].MaxFrameRate < 0 || c.VideoDecode[i].MaxBitrateKbps < 0 {
return errors.New("invalid detailed video capability")
}
if len(c.VideoDecode[i].Profiles) > 64 || len(c.VideoDecode[i].Levels) > 64 || len(c.VideoDecode[i].BitDepths) > 64 {
return errors.New("detailed video capability exceeds supported size")
}
for _, profile := range c.VideoDecode[i].Profiles {
if len(profile) > 64 {
return errors.New("detailed video capability value exceeds supported size")
}
}
}
for _, hdr := range []*HDRCapabilitiesV3{c.HDRDetails, ctx.Output.HDRDetails} {
if hdr != nil && len(hdr.DolbyVisionProfiles) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
}
for name, engine := range ctx.Engines {
if len(name) > 64 || len(engine.Containers) > 64 || len(engine.VideoCodecs) > 64 || len(engine.AudioDecodeCodecs) > 64 || len(engine.AudioPassthroughCodecs) > 64 || len(engine.Features) > 64 || len(engine.ValidatedClaims) > 64 || len(engine.Transformations) > 16 {
return errors.New("engine capability exceeds supported size")
}
if engine.HDRDetails != nil && len(engine.HDRDetails.DolbyVisionProfiles) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
for _, values := range [][]string{engine.Containers, engine.VideoCodecs, engine.AudioDecodeCodecs, engine.AudioPassthroughCodecs, engine.Features, engine.ValidatedClaims} {
for _, value := range values {
if len(value) > 64 {
return errors.New("engine capability value exceeds supported size")
}
}
}
seenTransformations := make(map[string]struct{}, len(engine.Transformations))
for i := range engine.Transformations {
transformation := &engine.Transformations[i]
transformation.Name = strings.ToLower(strings.TrimSpace(transformation.Name))
transformation.Executor = strings.ToLower(strings.TrimSpace(transformation.Executor))
transformation.RecipeVersion = strings.TrimSpace(transformation.RecipeVersion)
if transformation.Name == "" || len(transformation.Name) > 64 ||
(transformation.Executor != "client" && transformation.Executor != "server") ||
transformation.RecipeVersion == "" || len(transformation.RecipeVersion) > 32 ||
len(transformation.ValidatedClaims) > 32 {
return errors.New("invalid engine transformation capability")
}
if transformation.Executor == "client" {
if !engine.Enabled || !engine.SupportedOnDevice ||
(!HasFeatureV3(ctx.Features, FeatureClientVideoTransforms) && !HasFeatureV3(topLevelFeatures, FeatureClientVideoTransforms)) {
return errors.New("client transformation capability is not enabled")
}
}
key := transformation.Executor + ":" + transformation.Name + ":" + transformation.RecipeVersion
if _, exists := seenTransformations[key]; exists {
return errors.New("duplicate engine transformation capability")
}
seenTransformations[key] = struct{}{}
for _, claim := range transformation.ValidatedClaims {
if len(claim) > 128 {
return errors.New("transformation claim exceeds supported size")
}
}
}
ctx.Engines[name] = engine
}
for _, abi := range ctx.Device.ABIs {
if len(abi) > 64 {
return errors.New("device ABI exceeds supported size")
}
}
for _, passthrough := range []*AudioPassthroughV3{c.AudioPassthrough, ctx.Output.AudioPassthrough} {
if passthrough == nil {
continue
}
if len(passthrough.PassthroughCodecs) > 64 || len(passthrough.Entries) > 64 || passthrough.MaxChannels < 0 || passthrough.MaxChannels > 64 {
return errors.New("audio passthrough capability exceeds supported size")
}
for _, entry := range passthrough.Entries {
if len(entry.Codec) > 64 || len(entry.ChannelCounts) > 32 || len(entry.Layouts) > 32 {
return errors.New("audio passthrough entry exceeds supported size")
}
}
}
return nil
}
func validateTrackPairV3(fileID int, kind, id string, index *int) error {
if len(id) > 128 {
return fmt.Errorf("%s_track_id exceeds supported size", kind)
}
if index != nil && (*index < 0 || *index > 10_000) {
return fmt.Errorf("%s_track_index is invalid", kind)
}
if id == "" || index == nil {
return nil
}
want := TrackIDV3(fileID, kind, *index)
if id != want {
return fmt.Errorf("%s track id and index disagree", kind)
}
return nil
}
func validateOptionalBoundedIntV3(v *int, min, max int, name string) error {
if v != nil && (*v < min || *v > max) {
return fmt.Errorf("%s is outside the supported range", name)
}
return nil
}
func isFiniteV3(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
func HasFeatureV3(features []string, wanted string) bool {
return slices.ContainsFunc(features, func(v string) bool { return strings.EqualFold(strings.TrimSpace(v), wanted) })
}
func NewTerminalResponseV3(reason, message string, retryable bool) DecisionResponseV3 {
return DecisionResponseV3{
ProtocolVersion: ProtocolV3,
ServerFeatures: []string{FeaturePlaybackPlanV3, FeatureMedia3Only, FeatureDeviceQuirksV3, FeatureSeekReanchorV3},
Outcome: OutcomeAdaptationUnavailableV3,
Terminal: &TerminalV3{Reason: reason, Message: message, Retryable: retryable},
}
}
func DisabledResponseV3() DecisionResponseV3 {
return DecisionResponseV3{ProtocolVersion: ProtocolV3, ServerFeatures: []string{}}
}
func NewPlanExpiryV3(now time.Time) string { return now.Add(MaxTokenTTL).UTC().Format(time.RFC3339) }
@@ -0,0 +1,143 @@
package playback
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
)
func TestCheckReplacementAllowedTransfersExistingCapacitySlot(t *testing.T) {
manager := NewSessionManager(2, 1)
direct, err := manager.StartSession(1, "profile-1", 1, PlayDirect, false)
if err != nil {
t.Fatal(err)
}
if err := manager.CheckReplacementAllowed(context.Background(), direct.ID, PlayTranscode, true); err != nil {
t.Fatalf("direct to first transcode denied: %v", err)
}
if _, err := manager.StartSession(1, "profile-1", 2, PlayTranscode, true); !errors.Is(err, ErrTooManyTranscodes) {
t.Fatalf("reservation did not hold the transcode slot: %v", err)
}
manager.CancelReplacementReservation(direct.ID)
if _, err := manager.StartSession(1, "profile-1", 2, PlayTranscode, true); err != nil {
t.Fatal(err)
}
if err := manager.CheckReplacementAllowed(context.Background(), direct.ID, PlayTranscode, true); !errors.Is(err, ErrTooManyTranscodes) {
t.Fatalf("replacement error = %v, want ErrTooManyTranscodes", err)
}
}
func TestCheckReplacementAllowedRechecksDynamicTranscodePolicy(t *testing.T) {
manager := NewSessionManager(0, 0)
session, err := manager.StartSession(1, "profile-1", 1, PlayDirect, false)
if err != nil {
t.Fatal(err)
}
manager.SetLimitProvider(func(context.Context, int) (SessionLimits, error) {
return SessionLimits{TranscodingDisabled: true}, nil
})
if err := manager.CheckReplacementAllowed(context.Background(), session.ID, PlayTranscode, true); !errors.Is(err, ErrTranscodingDisabled) {
t.Fatalf("replacement error = %v, want ErrTranscodingDisabled", err)
}
}
func TestReconstructionOutputDirPreservesPlanScopedIsolation(t *testing.T) {
root := t.TempDir()
sessionID := "session-1"
want := filepath.Join(root, sessionID+"-plan-deadbeef")
if got := reconstructionOutputDir(root, sessionID, sessionID+"-plan-deadbeef"); got != want {
t.Fatalf("output dir = %q, want %q", got, want)
}
if got := reconstructionOutputDir(root, sessionID, filepath.Join("..", "escape")); got != filepath.Join(root, sessionID) {
t.Fatalf("unsafe output dir = %q", got)
}
}
func TestRecipeCardRoundTripPreservesRemoteTransportIdentity(t *testing.T) {
card := NewRecipeCard(1, "profile-1", 42, "https://node.example", TranscodeOpts{SessionID: "public-session", TranscodeTransportID: "public-session-plan-deadbeef", InputPath: "/media/movie.mkv", TargetCodecVideo: "h264", SegmentDuration: 2})
claims := card.ToClaims()
if claims.SessionID != "public-session" || claims.TranscodeTransportID != "public-session-plan-deadbeef" {
t.Fatalf("claims = %#v", claims)
}
roundTrip := RecipeCardFromClaims(&claims)
if roundTrip.SessionID != card.SessionID || roundTrip.TranscodeTransportID != card.TranscodeTransportID {
t.Fatalf("round trip = %#v, want %#v", roundTrip, card)
}
}
func TestMemoryPlanStoreV3StartAndReplanIdempotency(t *testing.T) {
store := NewMemoryPlanStoreV3()
record := AttemptRecordV3{PlaybackAttemptID: "attempt-0001", SessionID: "session-1", UserID: 1, ProfileID: "profile-1", CurrentPlanID: "plan-1", ExpiresAt: time.Now().Add(time.Hour)}
if err := store.SaveAttempt(context.Background(), record); err != nil {
t.Fatal(err)
}
duplicate := record
duplicate.SessionID = "session-2"
if err := store.SaveAttempt(context.Background(), duplicate); !errors.Is(err, ErrPlaybackAttemptExistsV3) {
t.Fatalf("duplicate start error = %v", err)
}
lease, err := store.BeginReplan(context.Background(), record.SessionID, "replan-0001", "digest-a", record.CurrentReplanRequestID, time.Now().Add(time.Minute))
if err != nil || lease.State != ReplanLeaseOwnedV3 {
t.Fatalf("first lease = %#v, err=%v", lease, err)
}
lease, err = store.BeginReplan(context.Background(), record.SessionID, "replan-0001", "digest-a", record.CurrentReplanRequestID, time.Now().Add(time.Minute))
if err != nil || lease.State != ReplanLeaseInFlightV3 {
t.Fatalf("in-flight lease = %#v, err=%v", lease, err)
}
if _, err := store.BeginReplan(context.Background(), record.SessionID, "replan-0001", "digest-b", record.CurrentReplanRequestID, time.Now().Add(time.Minute)); !errors.Is(err, ErrIdempotencyKeyReusedV3) {
t.Fatalf("digest conflict = %v", err)
}
response := json.RawMessage(`{"protocol_version":3}`)
if err := store.CompleteReplan(context.Background(), record.SessionID, "replan-0001", record.CurrentReplanRequestID, response, record); err != nil {
t.Fatal(err)
}
lease, err = store.BeginReplan(context.Background(), record.SessionID, "replan-0001", "digest-a", record.CurrentReplanRequestID, time.Now().Add(time.Minute))
if err != nil || lease.State != ReplanLeaseCompletedV3 || string(lease.Response) != string(response) {
t.Fatalf("completed lease = %#v, err=%v", lease, err)
}
}
func TestMemoryPlanStoreV3RejectsExpiredLeaseAfterNewerCommit(t *testing.T) {
store := NewMemoryPlanStoreV3()
record := AttemptRecordV3{
PlaybackAttemptID: "attempt-stale-lease",
SessionID: "session-stale-lease",
UserID: 1,
ProfileID: "profile-1",
CurrentPlanID: "plan-same",
CurrentReplanRequestID: "replan-base",
ExpiresAt: time.Now().Add(time.Hour),
}
if err := store.SaveAttempt(context.Background(), record); err != nil {
t.Fatal(err)
}
if lease, err := store.BeginReplan(
context.Background(), record.SessionID, "replan-abandoned", "digest-a",
record.CurrentReplanRequestID, time.Now().Add(-time.Second),
); err != nil || lease.State != ReplanLeaseOwnedV3 {
t.Fatalf("abandoned lease = %#v, err=%v", lease, err)
}
if lease, err := store.BeginReplan(
context.Background(), record.SessionID, "replan-newer", "digest-b",
record.CurrentReplanRequestID, time.Now().Add(time.Minute),
); err != nil || lease.State != ReplanLeaseOwnedV3 {
t.Fatalf("newer lease = %#v, err=%v", lease, err)
}
base := record.CurrentReplanRequestID
record.CurrentReplanRequestID = "replan-newer"
if err := store.CompleteReplan(
context.Background(), record.SessionID, "replan-newer", base,
json.RawMessage(`{"protocol_version":3}`), record,
); err != nil {
t.Fatal(err)
}
if _, err := store.BeginReplan(
context.Background(), record.SessionID, "replan-abandoned", "digest-a",
record.CurrentReplanRequestID, time.Now().Add(time.Minute),
); !errors.Is(err, ErrStaleReplanLeaseV3) {
t.Fatalf("expired stale lease error = %v", err)
}
}
+945
View File
@@ -0,0 +1,945 @@
package playback
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func hasDegradationWarningV3(warnings []DegradationWarningV3, code string) bool {
for _, warning := range warnings {
if warning.Code == code {
return true
}
}
return false
}
func TestStartRequestV3Validation(t *testing.T) {
index := 1
req := validStartRequestV3()
req.AudioTrackIndex = &index
req.AudioTrackID = TrackIDV3(req.FileID, "audio", index)
warnings, err := req.NormalizeAndValidate()
if err != nil {
t.Fatalf("NormalizeAndValidate: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("warnings = %#v, want none", warnings)
}
req.AudioTrackID = TrackIDV3(req.FileID, "audio", 2)
if _, err := req.NormalizeAndValidate(); err == nil {
t.Fatal("mismatched track id/index accepted")
}
}
func TestStartRequestV3UnknownQualityFallsBackToAuto(t *testing.T) {
req := validStartRequestV3()
req.QualityPreference = "future-super-quality"
warnings, err := req.NormalizeAndValidate()
if err != nil {
t.Fatalf("NormalizeAndValidate: %v", err)
}
if req.QualityPreference != "auto" || len(warnings) != 1 || warnings[0].Code != "quality_preference_normalized" {
t.Fatalf("quality=%q warnings=%#v", req.QualityPreference, warnings)
}
}
func TestReplanRequestV3OperationDefaultsAndValidates(t *testing.T) {
start := validStartRequestV3()
request := ReplanRequestV3{
ProtocolVersion: ProtocolV3,
PlaybackAttemptID: start.PlaybackAttemptID,
ReplanRequestID: "replan-operation-0001",
FailedPlanID: "plan:operation-0001",
PlanAttemptID: "plan-attempt-operation-0001",
PlanAttemptKey: "v3:0000000000000001",
AttemptCount: 1,
QualityPreference: start.QualityPreference,
OutputRouteGeneration: start.OutputRouteGeneration,
Failure: FailureV3{Classification: "parser_failure"},
Capabilities: start.Capabilities,
ClientPlaybackContext: start.ClientPlaybackContext,
}
if request.EffectiveOperation() != ReplanOperationFailureRecoveryV3 {
t.Fatalf("missing operation = %q", request.EffectiveOperation())
}
if err := request.Validate(); err != nil {
t.Fatalf("backward-compatible default operation: %v", err)
}
request.Operation = ReplanOperationSeekReanchorV3
request.Failure.Classification = ""
if err := request.Validate(); err != nil {
t.Fatalf("seek reanchor operation without fake failure: %v", err)
}
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
var wire map[string]any
if err := json.Unmarshal(body, &wire); err != nil {
t.Fatal(err)
}
if wire["operation"] != string(ReplanOperationSeekReanchorV3) {
t.Fatalf("serialized operation = %#v", wire["operation"])
}
request.Operation = ReplanOperationSeekFailureRecoveryV3
if err := request.Validate(); err == nil {
t.Fatal("seek failure recovery without a classification was accepted")
}
request.Failure.Classification = "decoder_failure"
if err := request.Validate(); err != nil {
t.Fatalf("seek failure recovery operation: %v", err)
}
request.Operation = "future_operation"
if err := request.Validate(); err == nil {
t.Fatal("unknown replan operation was accepted")
}
}
func TestReplanRequestV3RejectsInvalidNetworkAndTrackEvidence(t *testing.T) {
start := validStartRequestV3()
request := ReplanRequestV3{
ProtocolVersion: ProtocolV3,
PlaybackAttemptID: start.PlaybackAttemptID,
ReplanRequestID: "replan-validation-0001",
FailedPlanID: "plan:validation-0001",
PlanAttemptID: "plan-attempt-validation-0001",
PlanAttemptKey: "v3:0000000000000001",
AttemptCount: 1,
QualityPreference: start.QualityPreference,
OutputRouteGeneration: start.OutputRouteGeneration,
Failure: FailureV3{Classification: "parser_failure"},
Capabilities: start.Capabilities,
ClientPlaybackContext: start.ClientPlaybackContext,
}
negative := -1
request.SelectedTracks.Subtitle = &TrackIdentityV3{ID: "file:42:subtitle:-1", Index: &negative}
if err := request.Validate(); err == nil {
t.Fatal("negative subtitle index was accepted")
}
request.SelectedTracks.Subtitle = nil
tooLow := 99
request.BandwidthEstimateKbps = &tooLow
if err := request.Validate(); err == nil {
t.Fatal("out-of-range bandwidth estimate was accepted")
}
}
func TestPlanAttemptKeyV3KotlinFixture(t *testing.T) {
type fixture struct {
Name string `json:"name"`
PlanID string `json:"plan_id"`
Delivery DeliveryV3 `json:"delivery"`
StreamProtocol StreamProtocolV3 `json:"stream_protocol"`
Container string `json:"container"`
VideoCodec string `json:"video_codec"`
AudioCodec string `json:"audio_codec"`
Width int `json:"width"`
Height int `json:"height"`
BitrateKbps int `json:"bitrate_kbps"`
DynamicRange string `json:"dynamic_range"`
SubtitleMode SubtitleModeV3 `json:"subtitle_mode"`
Transformations []TransformationV3 `json:"transformations"`
AppliedQuirks []AppliedQuirkV3 `json:"applied_quirks"`
RuntimeCorrections []string `json:"runtime_corrections"`
OutputRouteGeneration int64 `json:"output_route_generation"`
LocalMutations []string `json:"local_mutations"`
Expected string `json:"expected"`
}
body, err := os.ReadFile("testdata/protocol_v3/attempt_keys.json")
if err != nil {
t.Fatal(err)
}
var fixtures []fixture
if err := json.Unmarshal(body, &fixtures); err != nil {
t.Fatal(err)
}
for _, value := range fixtures {
t.Run(value.Name, func(t *testing.T) {
plan := PlanV3{
PlanID: value.PlanID,
Delivery: value.Delivery,
Stream: StreamV3{Protocol: value.StreamProtocol, Container: value.Container},
EffectiveRecipe: EffectiveRecipeV3{VideoCodec: value.VideoCodec, AudioCodec: value.AudioCodec, Width: &value.Width, Height: &value.Height, BitrateKbps: &value.BitrateKbps, DynamicRange: value.DynamicRange},
Subtitle: SubtitleDecisionV3{Mode: value.SubtitleMode},
}
plan.Transformations = append(plan.Transformations, value.Transformations...)
// The quirk identity is conditionally omitted from the preimage
// when no quirks or runtime corrections apply; fixtures pin both
// arities so the Kotlin client reproduces the omission exactly.
plan.AppliedQuirks = append(plan.AppliedQuirks, value.AppliedQuirks...)
plan.RuntimeCorrections = append(plan.RuntimeCorrections, value.RuntimeCorrections...)
if got := PlanAttemptKeyV3(plan, value.OutputRouteGeneration, value.LocalMutations); got != value.Expected {
t.Fatalf("key = %q, want %q", got, value.Expected)
}
})
}
}
func TestProtocolV3GoldenWireFixtures(t *testing.T) {
startBody, err := os.ReadFile("testdata/protocol_v3/start_request.json")
if err != nil {
t.Fatal(err)
}
var start StartRequestV3
if err := json.Unmarshal(startBody, &start); err != nil {
t.Fatal(err)
}
if _, err := start.NormalizeAndValidate(); err != nil {
t.Fatalf("golden start request: %v", err)
}
replanBody, err := os.ReadFile("testdata/protocol_v3/replan_request.json")
if err != nil {
t.Fatal(err)
}
var replan ReplanRequestV3
if err := json.Unmarshal(replanBody, &replan); err != nil {
t.Fatal(err)
}
if err := replan.Validate(); err != nil {
t.Fatalf("golden replan request: %v", err)
}
if replan.BandwidthEstimateKbps == nil || *replan.BandwidthEstimateKbps != 3_500 ||
replan.BandwidthCapKbps == nil || *replan.BandwidthCapKbps != 4_000 || !replan.Metered {
t.Fatalf("golden replan network evidence = %#v", replan)
}
responseBody, err := os.ReadFile("testdata/protocol_v3/decision_response.json")
if err != nil {
t.Fatal(err)
}
var response DecisionResponseV3
if err := json.Unmarshal(responseBody, &response); err != nil {
t.Fatal(err)
}
if response.ProtocolVersion != ProtocolV3 || response.Outcome != OutcomePlayableV3 || response.PlaybackPlan == nil || response.PlaybackPlan.Stream.Protocol != StreamHTTPProgressiveV3 {
t.Fatalf("golden response = %#v", response)
}
}
func TestPlanPlaybackV3DirectRequiresDetailedEvidence(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3, FeatureLayoutPassthrough)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3, FeatureLayoutPassthrough)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
req.ClientFeatures = []string{FeaturePlaybackPlanV3, FeatureMedia3Only}
req.ClientPlaybackContext.Features = req.ClientFeatures
result = PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: false, Allow4KTranscode: true}})
if result.Terminal == nil || result.Terminal.Reason != "transcoding_disabled" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestSourceDescriptorV3NormalizesLegacyHEVCMetadata(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].BitDepth = 0
file.VideoTracks[0].PixelFormat = "yuv420p10le"
file.VideoTracks[0].DVProfile = 7
file.VideoTracks[0].DVELPresent = false
file.VideoTracks[0].DVEnhancementLayer = ""
file.VideoTracks[0].VideoRangeType = "DOVIWithEL"
source := SourceDescriptorFromFileV3(file, 0)
if source.BitDepth != 10 {
t.Fatalf("bit depth = %d, want inferred 10", source.BitDepth)
}
if source.DVEnhancementLayer != EnhancementUnknownV3 {
t.Fatalf("enhancement layer = %q, want unknown", source.DVEnhancementLayer)
}
}
func TestPlanPlaybackV3DirectPlaysLegacyHDR10WithInferredBitDepth(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].BitDepth = 0
file.VideoTracks[0].PixelFormat = "yuv420p10le"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || !result.Plan.Claims.Video.HDR10 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3DirectPlaysLegacyDolbyVisionProfile8(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].BitDepth = 0
file.VideoTracks[0].PixelFormat = "yuv420p10le"
file.VideoTracks[0].DVProfile = 8
file.VideoTracks[0].DVBLCompatID = 1
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithHDR10"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{8}}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || !result.Plan.Claims.Video.DolbyVision {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if result.Plan.RequestedMediaFileID != file.ID || result.Plan.EffectiveMediaFileID != file.ID {
t.Fatalf("source ids = requested %d effective %d", result.Plan.RequestedMediaFileID, result.Plan.EffectiveMediaFileID)
}
}
func TestPlanPlaybackV3RejectsTrulyIncompleteVideoMetadata(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].BitDepth = 0
file.VideoTracks[0].PixelFormat = ""
file.VideoTracks[0].Profile = ""
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Terminal == nil || result.Terminal.Reason != "source_metadata_incomplete" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3BlocksUltrawide4KTranscode(t *testing.T) {
file := detailedFixtureFileV3()
file.Resolution = "2160p"
file.VideoTracks[0].Width = 3840
file.VideoTracks[0].Height = 1626
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
req := validStartRequestV3()
req.QualityPreference = "1080p"
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false}, Registry: testTransformationRegistryV3()})
if result.Terminal == nil || result.Terminal.Reason != "no_alternate_version" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3Profile7FallsBackToHDR10WithoutNativeP7(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 7
file.VideoTracks[0].DVBLCompatID = 6
file.VideoTracks[0].DVELPresent = false
file.VideoTracks[0].DVEnhancementLayer = ""
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithEL"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{5, 8}}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
registry := NewTransformationRegistryV3([]TransformationSpecV3{{Name: "server_dv7_to_hdr10", Available: true}})
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: registry})
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxProgressiveV3 || !result.Plan.Claims.Video.HDR10 || result.Plan.Claims.Video.DolbyVision {
t.Fatalf("result = %#v", result)
}
if len(result.Plan.Transformations) != 1 || result.Plan.Transformations[0].Name != "server_dv7_to_hdr10" {
t.Fatalf("transformations = %#v", result.Plan.Transformations)
}
}
func TestPlanPlaybackV3Profile7UsesVersionedClientTransformationsOnSameFile(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 7
file.VideoTracks[0].DVBLCompatID = 1
file.VideoTracks[0].DVELPresent = true
file.VideoTracks[0].DVEnhancementLayer = "unknown"
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithEL"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3, FeatureClientVideoTransforms)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3, FeatureClientVideoTransforms)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{8}}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
direct := req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
direct.Transformations = []TransformationV3{
{Name: ClientDV7ToDV81V3, Executor: "client", RecipeVersion: ClientDVTransformVersionV3},
{Name: ClientDV7ToHDR10V3, Executor: "client", RecipeVersion: ClientDVTransformVersionV3},
}
req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = direct
first := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3()})
if first.Plan == nil || first.Plan.Delivery != DeliveryOriginalHTTPV3 || first.Plan.EffectiveMediaFileID != file.ID || first.Plan.DecisionReason != "client_dv7_to_dv81" {
t.Fatalf("first = %#v", first)
}
if got := first.Plan.Transformations; len(got) != 1 || got[0].Name != ClientDV7ToDV81V3 || got[0].Executor != "client" || got[0].RecipeVersion != "1" {
t.Fatalf("first transformations = %#v", got)
}
failedKey := PlanAttemptKeyV3(*first.Plan, req.OutputRouteGeneration, nil)
second := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3(), AttemptedKeys: []string{failedKey}})
if second.Plan == nil || second.Plan.Delivery != DeliveryOriginalHTTPV3 || second.Plan.EffectiveMediaFileID != file.ID || second.Plan.DecisionReason != "client_dv7_to_hdr10" || !second.Plan.Claims.Video.HDR10 {
t.Fatalf("second = %#v", second)
}
}
func TestPlanPlaybackV3Profile7DoesNotInferClientTransformFromHDRProfile(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 7
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithEL"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{7, 8}}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: false}})
if result.Plan != nil && result.Plan.Delivery == DeliveryOriginalHTTPV3 {
t.Fatalf("Profile 7 must not direct-play from codec claims alone: %#v", result.Plan)
}
}
func TestPlanPlaybackV3AudioAdaptationCopiesVideo(t *testing.T) {
file := detailedFixtureFileV3()
file.AudioTracks[0] = models.AudioTrack{Codec: "truehd", Channels: 8, Layout: "7.1"}
file.CodecAudio = "truehd"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxProgressiveV3 || !result.TranscodeAudio || result.TargetVideoCodec != "" {
t.Fatalf("result = %#v", result)
}
}
func TestPlanPlaybackV3FallsBackFromProgressiveToHLSWithoutRepeatingKey(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.Containers = []string{"mp4"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
first := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}})
if first.Plan == nil || first.Plan.Delivery != DeliveryRemuxProgressiveV3 {
t.Fatalf("first = %s", ExplainPlannerResultV3(first))
}
failedKey := PlanAttemptKeyV3(*first.Plan, req.OutputRouteGeneration, nil)
second := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AttemptedKeys: []string{failedKey}})
if second.Plan == nil || second.Plan.Delivery != DeliveryRemuxHLSV3 || second.TargetVideoCodec != "copy" {
t.Fatalf("second = %#v", second)
}
secondKey := PlanAttemptKeyV3(*second.Plan, req.OutputRouteGeneration, nil)
third := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(), AttemptedKeys: []string{failedKey, secondKey}})
if third.Plan == nil || third.Plan.Delivery != DeliveryTranscodeHLSV3 || third.Plan.DecisionReason != "copy_routes_exhausted" {
t.Fatalf("third = %#v", third)
}
}
func TestPlanPlaybackV3NeverClaimsUnimplementedHDRTranscode(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.QualityPreference = "1080p"
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3Profile7StripFallsBackToValidatedHLSCopy(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 7
file.VideoTracks[0].DVBLCompatID = 1
file.VideoTracks[0].DVELPresent = true
file.VideoTracks[0].DVEnhancementLayer = "mel"
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithEL"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.Containers = []string{"mp4"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{7}}
registry := NewTransformationRegistryV3([]TransformationSpecV3{{Name: "server_dv7_to_hdr10", Available: true}})
first := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: registry})
if first.Plan == nil || first.Plan.Delivery != DeliveryRemuxProgressiveV3 || len(first.Plan.Transformations) != 1 {
t.Fatalf("first = %#v", first)
}
failedKey := PlanAttemptKeyV3(*first.Plan, req.OutputRouteGeneration, nil)
second := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: registry, AttemptedKeys: []string{failedKey}})
if second.Plan == nil || second.Plan.Delivery != DeliveryRemuxHLSV3 || second.TargetVideoCodec != "copy" || len(second.Plan.Transformations) != 1 || second.Plan.Transformations[0].Name != "server_dv7_to_hdr10" {
t.Fatalf("second = %#v", second)
}
}
func TestPlanPlaybackV3Profile8CompatibleBaseLayerStripsToHDR10(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 8
file.VideoTracks[0].DVBLCompatID = 1
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVI"
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
registry := NewTransformationRegistryV3([]TransformationSpecV3{{Name: "server_dv7_to_hdr10", Available: true}})
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: registry})
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxProgressiveV3 || result.Plan.EffectiveRecipe.DynamicRange != "hdr10" || len(result.Plan.Transformations) != 1 || result.Plan.Transformations[0].Name != "server_dv7_to_hdr10" {
t.Fatalf("result = %#v", result)
}
}
func TestPlanPlaybackV3PassthroughRequiresExactLayoutEntry(t *testing.T) {
file := detailedFixtureFileV3()
file.CodecAudio = "truehd"
file.AudioTracks[0] = models.AudioTrack{Codec: "truehd", Channels: 8, Layout: "7.1"}
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3, FeatureLayoutPassthrough)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3, FeatureLayoutPassthrough)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.Capabilities.AudioPassthrough = &AudioPassthroughV3{PassthroughCodecs: []string{"truehd"}, MaxChannels: 8, Entries: []AudioPassthroughEntryV3{{Codec: "truehd"}}}
withoutLayout := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if withoutLayout.Plan == nil || !withoutLayout.TranscodeAudio {
t.Fatalf("without exact layout = %#v", withoutLayout)
}
req.Capabilities.AudioPassthrough.Entries[0].ChannelCounts = []int{8}
req.Capabilities.AudioPassthrough.Entries[0].Layouts = []string{"7.1"}
withLayout := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}})
if withLayout.Plan == nil || withLayout.Plan.Delivery != DeliveryOriginalHTTPV3 || !withLayout.Plan.Claims.Audio.Passthrough {
t.Fatalf("with exact layout = %#v", withLayout)
}
}
func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
index := 0
req.SubtitleTrackIndex = &index
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index)
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded"}}})
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID {
t.Fatalf("result = %#v", result)
}
}
func TestSubtitleBurnInUsesEmbeddedOrdinalAndRejectsUnsupportedSources(t *testing.T) {
file := detailedFixtureFileV3()
file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "ass"}}
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "hdmv_pgs_subtitle"}}
req := validStartRequestV3()
embeddedCombinedIndex := 1
req.SubtitleTrackIndex = &embeddedCombinedIndex
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", embeddedCombinedIndex)
result := ResolveSubtitlePolicyV3(file, req, true, EngineMedia3DirectV3, nil)
if !result.RequiresBurn || result.SelectedIndex != 1 || result.TransportIndex != 0 {
t.Fatalf("embedded burn-in result = %#v", result)
}
externalIndex := 0
req.SubtitleTrackIndex = &externalIndex
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", externalIndex)
req.SubtitleFidelityPreference = SubtitleFidelityPreserveV3
result = ResolveSubtitlePolicyV3(file, req, true, EngineMedia3DirectV3, nil)
if result.Terminal == nil || result.Terminal.Reason != "subtitle_burn_in_source_unsupported" {
t.Fatalf("external burn-in result = %#v", result)
}
}
func TestResolveQualityPolicyV3HonorsBandwidthCapInAllModes(t *testing.T) {
source := SourceDescriptorV3{Width: 3840, Height: 2160, BitrateKbps: 20_000}
cap := 5_000
req := validStartRequestV3()
req.QualityPreference = "original"
req.BandwidthCapKbps = &cap
result := ResolveQualityPolicyV3(req, source)
if !result.RequiresTranscode || result.Height != 720 || result.Reason != "quality_bandwidth_cap" || result.ExplicitRung {
t.Fatalf("original over cap = %#v", result)
}
if !hasDegradationWarningV3(result.Warnings, "bandwidth_cap_applied") {
t.Fatalf("missing cap warning: %#v", result.Warnings)
}
lowBitrateSource := SourceDescriptorV3{Width: 1920, Height: 1080, BitrateKbps: 4_000}
result = ResolveQualityPolicyV3(req, lowBitrateSource)
if !result.PreservesSource || result.RequiresTranscode || len(result.Warnings) != 0 {
t.Fatalf("original under cap = %#v", result)
}
req.QualityPreference = "1080p"
result = ResolveQualityPolicyV3(req, source)
if result.Height != 720 || result.Reason != "quality_bandwidth_cap" || !result.ExplicitRung || !result.RequiresTranscode {
t.Fatalf("fixed rung over cap = %#v", result)
}
if !hasDegradationWarningV3(result.Warnings, "bandwidth_cap_applied") {
t.Fatalf("missing cap warning: %#v", result.Warnings)
}
req.QualityPreference = "480p"
result = ResolveQualityPolicyV3(req, source)
if result.Height != 480 || result.Reason != "quality_fixed_rung" || hasDegradationWarningV3(result.Warnings, "bandwidth_cap_applied") {
t.Fatalf("fixed rung under cap = %#v", result)
}
req.QualityPreference = "auto"
result = ResolveQualityPolicyV3(req, source)
if !result.RequiresTranscode || result.Height != 720 || result.PreservesSource {
t.Fatalf("auto with cap = %#v", result)
}
}
func TestResolveQualityPolicyV3MeteredWithoutEvidencePrefersConservativeRung(t *testing.T) {
source := SourceDescriptorV3{Width: 3840, Height: 2160, BitrateKbps: 20_000}
req := validStartRequestV3()
req.QualityPreference = "auto"
req.Metered = true
result := ResolveQualityPolicyV3(req, source)
if result.Height != 720 || !result.RequiresTranscode || result.Reason != "quality_metered_limit" || result.ExplicitRung {
t.Fatalf("metered auto = %#v", result)
}
estimate := 30_000
req.BandwidthEstimateKbps = &estimate
result = ResolveQualityPolicyV3(req, source)
if !result.PreservesSource || result.Reason != "quality_bandwidth_limit" {
t.Fatalf("metered with estimate = %#v", result)
}
req.BandwidthEstimateKbps = nil
req.Metered = false
result = ResolveQualityPolicyV3(req, source)
if !result.PreservesSource || result.RequiresTranscode {
t.Fatalf("unmetered auto = %#v", result)
}
}
func TestPlanPlaybackV3HDRDeviceCapFallsBackToOriginalQuality(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.QualityPreference = "auto"
req.Capabilities.MaxResolution = "1080p"
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || !result.Plan.Claims.Video.HDR10 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if result.Plan.EffectiveRecipe.Height == nil || *result.Plan.EffectiveRecipe.Height != 2160 {
t.Fatalf("recipe = %#v", result.Plan.EffectiveRecipe)
}
if !hasDegradationWarningV3(result.Plan.DegradationWarnings, "quality_reduction_unavailable") {
t.Fatalf("warnings = %#v", result.Plan.DegradationWarnings)
}
}
func TestPlanPlaybackV3HDRBandwidthCapFallsBackToOriginalQuality(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.QualityPreference = "auto"
cap := 5_000
req.BandwidthCapKbps = &cap
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || !result.Plan.Claims.Video.HDR10 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if !hasDegradationWarningV3(result.Plan.DegradationWarnings, "quality_reduction_unavailable") {
t.Fatalf("warnings = %#v", result.Plan.DegradationWarnings)
}
}
func TestPlanPlaybackV3CapWithoutTranscodeRouteFallsBackToOriginal(t *testing.T) {
file := detailedFixtureFileV3()
file.Resolution = "1080p"
file.VideoTracks[0].Width = 1920
file.VideoTracks[0].Height = 1080
file.VideoTracks[0].Bitrate = 8_000
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
req := validStartRequestV3()
req.QualityPreference = "original"
cap := 4_000
req.BandwidthCapKbps = &cap
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
// The client has no HLS engine, so the cap-induced transcode cannot run.
delete(req.ClientPlaybackContext.Engines, string(EngineMedia3HLSV3))
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if !hasDegradationWarningV3(result.Plan.DegradationWarnings, "quality_reduction_unavailable") {
t.Fatalf("warnings = %#v", result.Plan.DegradationWarnings)
}
}
func TestPlanPlaybackV3LegacyHDRUnknownAssumesHDR10ForCapableClients(t *testing.T) {
file := detailedFixtureFileV3()
file.HDR = true
file.VideoTracks[0].VideoRange = ""
file.VideoTracks[0].VideoRangeType = ""
file.VideoTracks[0].ColorTransfer = ""
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = req.Capabilities.HDRDetails
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || !result.Plan.Claims.Video.HDR10 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if !hasDegradationWarningV3(result.Plan.DegradationWarnings, "hdr_range_assumed_hdr10") {
t.Fatalf("warnings = %#v", result.Plan.DegradationWarnings)
}
// Without HDR10 output support the legacy row keeps the previous
// (ineligible) behavior instead of guessing at the client's range.
req.Capabilities.HDRDetails = nil
req.ClientPlaybackContext.Output.HDRDetails = nil
result = PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestSourceDescriptorV3NormalizesLegacyFileBitrateFallback(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].Bitrate = 0
file.Bitrate = 60_000_000 // legacy rows stored bps
source := SourceDescriptorFromFileV3(file, 0)
if source.BitrateKbps != 60_000 {
t.Fatalf("file-level bitrate = %d, want normalized 60000", source.BitrateKbps)
}
file.VideoTracks[0].Bitrate = 45_000_000
source = SourceDescriptorFromFileV3(file, 0)
if source.BitrateKbps != 45_000 {
t.Fatalf("track-level bitrate = %d, want normalized 45000", source.BitrateKbps)
}
}
func TestPlanAttemptedV3RequiresExactKeyMatch(t *testing.T) {
plan := PlanV3{PlanID: "plan:exact", Delivery: DeliveryOriginalHTTPV3, Stream: StreamV3{Protocol: StreamHTTPProgressiveV3, Container: "mkv"}, Subtitle: SubtitleDecisionV3{Mode: SubtitleOffV3}}
key := PlanAttemptKeyV3(plan, 1, nil)
if !planAttemptedV3(plan, 1, []string{" " + key + " "}) {
t.Fatal("whitespace-trimmed exact key must match")
}
if planAttemptedV3(plan, 1, []string{strings.ToUpper(key)}) {
t.Fatal("case-folded attempt key must not match an exact hash")
}
}
func TestStartRequestV3ValidationBoundsInnerLists(t *testing.T) {
longValue := strings.Repeat("x", 65)
cases := []struct {
name string
mutate func(*StartRequestV3)
}{
{"video_decode_profile_count", func(r *StartRequestV3) {
r.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Hardware: true, Profiles: make([]string, 65)}}
}},
{"video_decode_profile_length", func(r *StartRequestV3) {
r.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Hardware: true, Profiles: []string{longValue}}}
}},
{"video_decode_level_count", func(r *StartRequestV3) {
r.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Hardware: true, Levels: make([]int, 65)}}
}},
{"video_decode_bit_depth_count", func(r *StartRequestV3) {
r.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Hardware: true, BitDepths: make([]int, 65)}}
}},
{"capability_dolby_vision_profiles", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfiles: make([]int, 17)}
}},
{"output_dolby_vision_profiles", func(r *StartRequestV3) {
r.ClientPlaybackContext.Output.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfiles: make([]int, 17)}
}},
{"engine_dolby_vision_profiles", func(r *StartRequestV3) {
engine := r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
engine.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfiles: make([]int, 17)}
r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = engine
}},
{"engine_container_length", func(r *StartRequestV3) {
engine := r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
engine.Containers = []string{longValue}
r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = engine
}},
{"engine_validated_claim_length", func(r *StartRequestV3) {
engine := r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
engine.ValidatedClaims = []string{longValue}
r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = engine
}},
{"engine_feature_length", func(r *StartRequestV3) {
engine := r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)]
engine.Features = []string{longValue}
r.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = engine
}},
{"audio_track_id_length", func(r *StartRequestV3) {
r.AudioTrackID = strings.Repeat("a", 129)
}},
{"subtitle_track_id_length", func(r *StartRequestV3) {
r.SubtitleTrackID = strings.Repeat("s", 129)
}},
}
for _, value := range cases {
t.Run(value.name, func(t *testing.T) {
req := validStartRequestV3()
value.mutate(&req)
if _, err := req.NormalizeAndValidate(); err == nil {
t.Fatal("oversized capability accepted")
}
})
}
}
func TestResolveQualityPolicyV3PreservesNonStandardSourceHeight(t *testing.T) {
req := validStartRequestV3()
req.QualityPreference = "auto"
result := ResolveQualityPolicyV3(req, SourceDescriptorV3{Width: 2560, Height: 1440, BitrateKbps: 12_000})
if !result.PreservesSource || result.RequiresTranscode || result.Width != 2560 || result.Height != 1440 || result.Label != "1440p" {
t.Fatalf("quality result = %#v", result)
}
}
func TestPlanPlaybackV3SeekedHLSCopyPreservesVideo(t *testing.T) {
file := detailedFixtureFileV3()
file.Resolution = "1080p"
file.VideoTracks[0].Width = 1920
file.VideoTracks[0].Height = 1080
file.VideoTracks[0].BitDepth = 8
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
req := validStartRequestV3()
start := 20.0
req.StartPosition = &start
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.Containers = []string{"mp4"}
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.ClientPlaybackContext.Engines[string(EngineMedia3DirectV3)] = EngineCapabilityV3{}
req.ClientPlaybackContext.Engines[string(EngineMedia3ProgressiveRemuxV3)] = EngineCapabilityV3{}
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxHLSV3 || result.TargetVideoCodec != "copy" || result.Plan.Timeline.SourceStartSeconds != start {
t.Fatalf("result = %#v", result)
}
}
func TestPlanPlaybackV3TimelineChangePreservesRouteIdentity(t *testing.T) {
file := detailedFixtureFileV3()
request := validStartRequestV3()
request.ClientFeatures = append(request.ClientFeatures, FeatureDetailedDecodeV3)
request.ClientPlaybackContext.Features = append(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
request.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
hdr := &HDRCapabilitiesV3{HDR10: true}
request.Capabilities.HDR = true
request.Capabilities.HDRDetails = hdr
request.ClientPlaybackContext.Output.HDRDetails = hdr
startAtZero := 0.0
request.StartPosition = &startAtZero
first := PlanPlaybackV3(PlannerInputV3{
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(),
})
if first.Plan == nil {
t.Fatalf("initial plan = %#v", first)
}
startAtSeek := 321.25
request.StartPosition = &startAtSeek
second := PlanPlaybackV3(PlannerInputV3{
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(),
})
if second.Plan == nil {
t.Fatalf("seeked plan = %#v", second)
}
if first.Plan.PlanID != second.Plan.PlanID ||
PlanAttemptKeyV3(*first.Plan, request.OutputRouteGeneration, nil) != PlanAttemptKeyV3(*second.Plan, request.OutputRouteGeneration, nil) {
t.Fatalf("timeline changed route identity: first=%#v second=%#v", first.Plan, second.Plan)
}
if first.Plan.Timeline.SourceStartSeconds != 0 || second.Plan.Timeline.SourceStartSeconds != startAtSeek {
t.Fatalf("timeline positions: first=%#v second=%#v", first.Plan.Timeline, second.Plan.Timeline)
}
}
func validStartRequestV3() StartRequestV3 {
return StartRequestV3{
ProtocolVersion: ProtocolV3,
ClientFeatures: []string{FeaturePlaybackPlanV3, FeatureMedia3Only},
FileID: 42,
ProfileID: "profile-1",
PlaybackAttemptID: "attempt-0001",
QualityPreference: "original",
SubtitleFidelityPreference: SubtitleFidelityCompatibleV3,
OutputRouteGeneration: 1,
Capabilities: ClientCodecCapabilitiesV3{CodecsVideo: []string{"hevc"}, CodecsAudio: []string{"aac"}, Containers: []string{"mkv"}, MaxResolution: "2160p"},
ClientPlaybackContext: ClientPlaybackContextV3{ProtocolVersion: ProtocolV3, Features: []string{FeaturePlaybackPlanV3, FeatureMedia3Only}, Platform: "android", FormFactor: "tv", AppVersion: "test", Output: OutputContextV3{OutputRouteGeneration: 1}, Engines: map[string]EngineCapabilityV3{
string(EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}},
string(EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
string(EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
}},
}
}
func detailedFixtureFileV3() *models.MediaFile {
return &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", Container: "mkv", CodecVideo: "hevc", CodecAudio: "aac", Resolution: "2160p", Bitrate: 60_000, AudioChannels: 2, VideoTracks: []models.VideoTrack{{Codec: "hevc", Profile: "Main 10", Level: 153, Width: 3840, Height: 2160, FrameRate: "24000/1001", Bitrate: 60_000, BitDepth: 10, VideoRange: "HDR", VideoRangeType: "HDR10"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
}
func testTransformationRegistryV3() *TransformationRegistryV3 {
return NewTransformationRegistryV3([]TransformationSpecV3{
{Name: "audio_to_aac", Available: true},
{Name: "video_to_h264", Available: true},
{Name: "server_dv7_to_hdr10", Available: true},
})
}
+137 -114
View File
@@ -16,11 +16,12 @@ import (
// context, channels, log sink). Those are re-wired on reconstruct from the
// live config and request.
type RecipeCard struct {
SessionID string `json:"session_id"`
UserID int `json:"user_id"`
ProfileID string `json:"profile_id"`
MediaFileID int `json:"media_file_id"`
TranscodeNodeURL string `json:"transcode_node_url,omitempty"`
SessionID string `json:"session_id"`
UserID int `json:"user_id"`
ProfileID string `json:"profile_id"`
MediaFileID int `json:"media_file_id"`
TranscodeNodeURL string `json:"transcode_node_url,omitempty"`
TranscodeTransportID string `json:"transcode_transport_id,omitempty"`
// PlayMethod discriminates which serve path reconstructs this session
// (direct / remux / transcode). Empty decodes as PlayTranscode for
@@ -28,27 +29,30 @@ type RecipeCard struct {
PlayMethod PlayMethod `json:"play_method,omitempty"`
// TranscodeAudio mirrors Session.TranscodeAudio; used by the remux path to
// re-spawn ffmpeg with the same audio handling on reconstruct.
TranscodeAudio bool `json:"transcode_audio,omitempty"`
TranscodeAudio bool `json:"transcode_audio,omitempty"`
RemuxDVMode RemuxDVMode `json:"remux_dv_mode,omitempty"`
// Encode parameters — mirror of the byte-affecting TranscodeOpts fields.
// Unused (zero) for direct/remux cards, which carry no segment-based encode.
InputPath string `json:"input_path"`
SourceVideoCodec string `json:"source_video_codec,omitempty"`
SeekSeconds float64 `json:"seek_seconds"`
TargetResolution string `json:"target_resolution,omitempty"`
TargetCodecVideo string `json:"target_codec_video,omitempty"`
TargetCodecAudio string `json:"target_codec_audio,omitempty"`
SegmentDuration int `json:"segment_duration"`
StartSegmentNumber int `json:"start_segment_number"`
HWAccel string `json:"hw_accel,omitempty"`
HWDevice string `json:"hw_device,omitempty"`
SubtitleTrackIndex int `json:"subtitle_track_index"`
SubtitleBurnIn bool `json:"subtitle_burn_in,omitempty"`
SubtitleCodec string `json:"subtitle_codec,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"`
TotalDuration float64 `json:"total_duration"`
FastStart bool `json:"fast_start,omitempty"`
InputPath string `json:"input_path"`
OutputSubdir string `json:"output_subdir,omitempty"`
SourceVideoCodec string `json:"source_video_codec,omitempty"`
VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"`
SeekSeconds float64 `json:"seek_seconds"`
TargetResolution string `json:"target_resolution,omitempty"`
TargetCodecVideo string `json:"target_codec_video,omitempty"`
TargetCodecAudio string `json:"target_codec_audio,omitempty"`
SegmentDuration int `json:"segment_duration"`
StartSegmentNumber int `json:"start_segment_number"`
HWAccel string `json:"hw_accel,omitempty"`
HWDevice string `json:"hw_device,omitempty"`
SubtitleTrackIndex int `json:"subtitle_track_index"`
SubtitleBurnIn bool `json:"subtitle_burn_in,omitempty"`
SubtitleCodec string `json:"subtitle_codec,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"`
TotalDuration float64 `json:"total_duration"`
FastStart bool `json:"fast_start,omitempty"`
}
// NewRecipeCard builds a RecipeCard from the durable identity fields plus the
@@ -58,29 +62,32 @@ type RecipeCard struct {
// operator's config change applies to reconstructed sessions too.
func NewRecipeCard(userID int, profileID string, mediaFileID int, transcodeNodeURL string, opts TranscodeOpts) RecipeCard {
return RecipeCard{
SessionID: opts.SessionID,
UserID: userID,
ProfileID: profileID,
MediaFileID: mediaFileID,
TranscodeNodeURL: transcodeNodeURL,
PlayMethod: PlayTranscode,
InputPath: opts.InputPath,
SourceVideoCodec: opts.SourceVideoCodec,
SeekSeconds: opts.SeekSeconds,
TargetResolution: opts.TargetResolution,
TargetCodecVideo: opts.TargetCodecVideo,
TargetCodecAudio: opts.TargetCodecAudio,
SegmentDuration: opts.SegmentDuration,
StartSegmentNumber: opts.StartSegmentNumber,
HWAccel: opts.HWAccel,
HWDevice: opts.HWDevice,
SubtitleTrackIndex: opts.SubtitleTrackIndex,
SubtitleBurnIn: opts.SubtitleBurnIn,
SubtitleCodec: opts.SubtitleCodec,
AudioTrackIndex: opts.AudioTrackIndex,
TargetBitrateKbps: opts.TargetBitrateKbps,
TotalDuration: opts.TotalDuration,
FastStart: opts.FastStart,
SessionID: opts.SessionID,
UserID: userID,
ProfileID: profileID,
MediaFileID: mediaFileID,
TranscodeNodeURL: transcodeNodeURL,
TranscodeTransportID: opts.TranscodeTransportID,
PlayMethod: PlayTranscode,
InputPath: opts.InputPath,
OutputSubdir: opts.OutputSubdir,
SourceVideoCodec: opts.SourceVideoCodec,
VideoBitstreamFilter: opts.VideoBitstreamFilter,
SeekSeconds: opts.SeekSeconds,
TargetResolution: opts.TargetResolution,
TargetCodecVideo: opts.TargetCodecVideo,
TargetCodecAudio: opts.TargetCodecAudio,
SegmentDuration: opts.SegmentDuration,
StartSegmentNumber: opts.StartSegmentNumber,
HWAccel: opts.HWAccel,
HWDevice: opts.HWDevice,
SubtitleTrackIndex: opts.SubtitleTrackIndex,
SubtitleBurnIn: opts.SubtitleBurnIn,
SubtitleCodec: opts.SubtitleCodec,
AudioTrackIndex: opts.AudioTrackIndex,
TargetBitrateKbps: opts.TargetBitrateKbps,
TotalDuration: opts.TotalDuration,
FastStart: opts.FastStart,
}
}
@@ -101,7 +108,11 @@ func NewDirectRecipeCard(sessionID string, userID int, profileID string, mediaFi
// NewRemuxRecipeCard builds a card for a remux session: identity plus the audio
// selection. The remux ffmpeg is a single pipe re-spawned at the client-supplied
// ?seek= on the next request, so no segment/encode parameters are pinned.
func NewRemuxRecipeCard(sessionID string, userID int, profileID string, mediaFileID int, transcodeAudio bool, audioTrackIndex int) RecipeCard {
func NewRemuxRecipeCard(sessionID string, userID int, profileID string, mediaFileID int, transcodeAudio bool, audioTrackIndex int, dvMode ...RemuxDVMode) RecipeCard {
mode := RemuxDVMode("")
if len(dvMode) > 0 {
mode = dvMode[0]
}
return RecipeCard{
SessionID: sessionID,
UserID: userID,
@@ -109,6 +120,7 @@ func NewRemuxRecipeCard(sessionID string, userID int, profileID string, mediaFil
MediaFileID: mediaFileID,
PlayMethod: PlayRemux,
TranscodeAudio: transcodeAudio,
RemuxDVMode: mode,
AudioTrackIndex: audioTrackIndex,
}
}
@@ -118,29 +130,32 @@ func NewRemuxRecipeCard(sessionID string, userID int, profileID string, mediaFil
// they are environment-specific and not pinned in the card.
func (c RecipeCard) TranscodeOpts(outputDir, ffmpegPath string, logSink FFmpegLogSink) TranscodeOpts {
return TranscodeOpts{
InputPath: c.InputPath,
OutputDir: outputDir,
SessionID: c.SessionID,
SourceVideoCodec: c.SourceVideoCodec,
SeekSeconds: c.SeekSeconds,
TargetResolution: c.TargetResolution,
TargetCodecVideo: c.TargetCodecVideo,
TargetCodecAudio: c.TargetCodecAudio,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
FFmpegPath: ffmpegPath,
HWAccel: c.HWAccel,
HWDevice: c.HWDevice,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
AudioTrackIndex: c.AudioTrackIndex,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
NodeType: "integrated",
ExecutionMode: "integrated",
FFmpegLogSink: logSink,
InputPath: c.InputPath,
OutputSubdir: c.OutputSubdir,
OutputDir: outputDir,
SessionID: c.SessionID,
TranscodeTransportID: c.TranscodeTransportID,
SourceVideoCodec: c.SourceVideoCodec,
VideoBitstreamFilter: c.VideoBitstreamFilter,
SeekSeconds: c.SeekSeconds,
TargetResolution: c.TargetResolution,
TargetCodecVideo: c.TargetCodecVideo,
TargetCodecAudio: c.TargetCodecAudio,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
FFmpegPath: ffmpegPath,
HWAccel: c.HWAccel,
HWDevice: c.HWDevice,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
AudioTrackIndex: c.AudioTrackIndex,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
NodeType: "integrated",
ExecutionMode: "integrated",
FFmpegLogSink: logSink,
}
}
@@ -159,28 +174,32 @@ const MaxTokenTTL = 24 * time.Hour
// change applies to reconstructed sessions too.
func (c RecipeCard) ToClaims() streamtoken.Claims {
return streamtoken.Claims{
SessionID: c.SessionID,
MediaPath: c.InputPath,
PlayMethod: string(c.PlayMethod),
TranscodeAudio: c.TranscodeAudio,
TranscodeNode: c.TranscodeNodeURL,
TargetCodec: c.TargetCodecVideo,
TargetRes: c.TargetResolution,
AudioTrackIndex: c.AudioTrackIndex,
UserID: c.UserID,
ProfileID: c.ProfileID,
MediaFileID: c.MediaFileID,
SourceVideoCodec: c.SourceVideoCodec,
SeekSeconds: c.SeekSeconds,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
TargetCodecAudio: c.TargetCodecAudio,
SessionID: c.SessionID,
MediaPath: c.InputPath,
OutputSubdir: c.OutputSubdir,
PlayMethod: string(c.PlayMethod),
TranscodeAudio: c.TranscodeAudio,
RemuxDVMode: string(c.RemuxDVMode),
TranscodeNode: c.TranscodeNodeURL,
TranscodeTransportID: c.TranscodeTransportID,
TargetCodec: c.TargetCodecVideo,
TargetRes: c.TargetResolution,
AudioTrackIndex: c.AudioTrackIndex,
UserID: c.UserID,
ProfileID: c.ProfileID,
MediaFileID: c.MediaFileID,
SourceVideoCodec: c.SourceVideoCodec,
VideoBitstreamFilter: c.VideoBitstreamFilter,
SeekSeconds: c.SeekSeconds,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
TargetCodecAudio: c.TargetCodecAudio,
}
}
@@ -197,27 +216,31 @@ func RecipeCardFromClaims(c *streamtoken.Claims) RecipeCard {
method = PlayTranscode
}
return RecipeCard{
SessionID: c.SessionID,
UserID: c.UserID,
ProfileID: c.ProfileID,
MediaFileID: c.MediaFileID,
TranscodeNodeURL: c.TranscodeNode,
PlayMethod: method,
TranscodeAudio: c.TranscodeAudio,
InputPath: c.MediaPath,
SourceVideoCodec: c.SourceVideoCodec,
SeekSeconds: c.SeekSeconds,
TargetResolution: c.TargetRes,
TargetCodecVideo: c.TargetCodec,
TargetCodecAudio: c.TargetCodecAudio,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
AudioTrackIndex: c.AudioTrackIndex,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
SessionID: c.SessionID,
UserID: c.UserID,
ProfileID: c.ProfileID,
MediaFileID: c.MediaFileID,
TranscodeNodeURL: c.TranscodeNode,
TranscodeTransportID: c.TranscodeTransportID,
PlayMethod: method,
TranscodeAudio: c.TranscodeAudio,
RemuxDVMode: RemuxDVMode(c.RemuxDVMode),
InputPath: c.MediaPath,
OutputSubdir: c.OutputSubdir,
SourceVideoCodec: c.SourceVideoCodec,
VideoBitstreamFilter: c.VideoBitstreamFilter,
SeekSeconds: c.SeekSeconds,
TargetResolution: c.TargetRes,
TargetCodecVideo: c.TargetCodec,
TargetCodecAudio: c.TargetCodecAudio,
SegmentDuration: c.SegmentDuration,
StartSegmentNumber: c.StartSegmentNumber,
SubtitleTrackIndex: c.SubtitleTrackIndex,
SubtitleBurnIn: c.SubtitleBurnIn,
SubtitleCodec: c.SubtitleCodec,
AudioTrackIndex: c.AudioTrackIndex,
TargetBitrateKbps: c.TargetBitrateKbps,
TotalDuration: c.TotalDuration,
FastStart: c.FastStart,
}
}
+41 -35
View File
@@ -9,25 +9,26 @@ import (
func TestRecipeCardRoundTripOpts(t *testing.T) {
opts := TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/silo-transcode/abc",
SessionID: "abc",
SourceVideoCodec: "hevc",
SeekSeconds: 900,
TargetResolution: "1080p",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
StartSegmentNumber: 450,
HWAccel: "qsv",
HWDevice: "/dev/dri/renderD128",
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
AudioTrackIndex: 1,
TargetBitrateKbps: 8000,
TotalDuration: 7200,
FastStart: true,
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/silo-transcode/abc",
SessionID: "abc",
SourceVideoCodec: "hevc",
VideoBitstreamFilter: "dovi_rpu=strip=1",
SeekSeconds: 900,
TargetResolution: "1080p",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
StartSegmentNumber: 450,
HWAccel: "qsv",
HWDevice: "/dev/dri/renderD128",
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
AudioTrackIndex: 1,
TargetBitrateKbps: 8000,
TotalDuration: 7200,
FastStart: true,
}
card := NewRecipeCard(42, "profile-1", 77, "", opts)
@@ -55,6 +56,9 @@ func TestRecipeCardRoundTripOpts(t *testing.T) {
if got.TargetCodecVideo != "h264" || got.TargetBitrateKbps != 8000 {
t.Errorf("encode params wrong: %+v", got)
}
if got.VideoBitstreamFilter != "dovi_rpu=strip=1" {
t.Errorf("VideoBitstreamFilter = %q", got.VideoBitstreamFilter)
}
if got.FFmpegPath != "/usr/bin/ffmpeg" {
t.Errorf("FFmpegPath not re-supplied: %q", got.FFmpegPath)
}
@@ -97,22 +101,23 @@ func TestRecipeCardLegacyDecodeHasEmptyPlayMethod(t *testing.T) {
// not asserted here.
func TestRecipeCardClaimsRoundTrip(t *testing.T) {
card := NewRecipeCard(42, "profile-1", 77, "http://node:9000", TranscodeOpts{
InputPath: "/media/movie.mkv",
SessionID: "abc",
SourceVideoCodec: "hevc",
SeekSeconds: 900,
TargetResolution: "1080p",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
StartSegmentNumber: 450,
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
AudioTrackIndex: 1,
TargetBitrateKbps: 8000,
TotalDuration: 7200,
FastStart: true,
InputPath: "/media/movie.mkv",
SessionID: "abc",
SourceVideoCodec: "hevc",
VideoBitstreamFilter: "dovi_rpu=strip=1",
SeekSeconds: 900,
TargetResolution: "1080p",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
StartSegmentNumber: 450,
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
AudioTrackIndex: 1,
TargetBitrateKbps: 8000,
TotalDuration: 7200,
FastStart: true,
})
claims := card.ToClaims()
@@ -126,6 +131,7 @@ func TestRecipeCardClaimsRoundTrip(t *testing.T) {
}
// Byte-affecting encode parameters.
if got.InputPath != card.InputPath || got.SourceVideoCodec != card.SourceVideoCodec ||
got.VideoBitstreamFilter != card.VideoBitstreamFilter ||
got.SeekSeconds != card.SeekSeconds || got.TargetResolution != card.TargetResolution ||
got.TargetCodecVideo != card.TargetCodecVideo || got.TargetCodecAudio != card.TargetCodecAudio ||
got.SegmentDuration != card.SegmentDuration || got.StartSegmentNumber != card.StartSegmentNumber ||
+113 -22
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"strconv"
"strings"
"sync"
"github.com/Silo-Server/silo-server/internal/httpstream"
@@ -19,8 +20,8 @@ var (
resolvedFFmpegPath string
ffmpegOnce sync.Once
doviRPUAvailable bool
doviRPUOnce sync.Once
doviRPUMu sync.Mutex
doviRPUCache map[string]bool
)
// ffmpegBinary returns the path to the ffmpeg binary.
@@ -37,18 +38,38 @@ func ffmpegBinary() string {
return resolvedFFmpegPath
}
// supportsDoviRPUFilter reports whether the resolved ffmpeg binary ships the
// dovi_rpu bitstream filter (FFmpeg 7.1+). Probed once per process.
func supportsDoviRPUFilter() bool {
doviRPUOnce.Do(func() {
out, err := exec.Command(ffmpegBinary(), "-hide_banner", "-bsfs").Output()
doviRPUAvailable = err == nil && bytes.Contains(out, []byte("dovi_rpu"))
if !doviRPUAvailable {
slog.Warn("ffmpeg lacks the dovi_rpu bitstream filter (needs FFmpeg 7.1+); " +
"Dolby Vision profile 7 remuxes will keep their dangling dual-layer RPUs")
}
})
return doviRPUAvailable
// ResolveFFmpegPath returns the ffmpeg binary the playback pipeline executes
// for the given configured path: the configured path when set, otherwise the
// process-global discovery (jellyfin-ffmpeg install, then PATH). Capability
// probes must resolve through this same function so a feature advertised at
// planning time is guaranteed present in the binary that later runs.
func ResolveFFmpegPath(configured string) string {
if strings.TrimSpace(configured) != "" {
return configured
}
return ffmpegBinary()
}
// supportsDoviRPUFilter reports whether the given FFmpeg binary can strip
// Dolby Vision RPU metadata via the dovi_rpu bitstream filter (FFmpeg 7.1+).
// The enhancement layer itself is dropped by stream mapping, so stripping the
// RPUs yields a clean HDR10 base layer. Probed once per binary path.
func supportsDoviRPUFilter(bin string) bool {
doviRPUMu.Lock()
defer doviRPUMu.Unlock()
if available, ok := doviRPUCache[bin]; ok {
return available
}
out, err := exec.Command(bin, "-hide_banner", "-bsfs").Output()
available := err == nil && bytes.Contains(out, []byte("dovi_rpu"))
if !available {
slog.Warn("ffmpeg lacks the dovi_rpu bitstream filter (needs FFmpeg 7.1+); validated Profile 7 HDR10 remux is disabled", "ffmpeg", bin)
}
if doviRPUCache == nil {
doviRPUCache = make(map[string]bool)
}
doviRPUCache[bin] = available
return available
}
// remuxDVProfile neutralizes a Dolby Vision profile the local ffmpeg cannot
@@ -71,9 +92,20 @@ type RemuxSession struct {
outputPipe io.ReadCloser
}
// RemuxDVMode makes Profile 7 handling an explicit byte-level recipe choice.
// The empty/legacy mode exists only for pre-v3 callers and old stream tokens.
type RemuxDVMode string
const (
RemuxDVLegacyAutoV3 RemuxDVMode = "legacy_auto"
RemuxDVPreserveV3 RemuxDVMode = "preserve"
RemuxDVStripToHDR10V3 RemuxDVMode = "strip_to_hdr10"
RemuxDVRejectP7V3 RemuxDVMode = "reject_profile_7"
)
// buildRemuxArgs constructs the ffmpeg argument list for a remux operation.
// The args perform codec copy (-c copy) into the target container format,
// using fragmented output for streaming (frag_keyframe+empty_moov+default_base_moof) and
// using fragmented output for streaming (frag_keyframe+delay_moov+default_base_moof) and
// pipe:1 for stdout output.
// When transcodeAudio is true, video is copied but audio is transcoded to
// stereo AAC (handles cases like DTS/TrueHD that browsers cannot decode).
@@ -82,7 +114,7 @@ type RemuxSession struct {
// the RPUs would dangle — stripping yields a clean HDR10 base layer (the
// Apple-parity fallback for devices without a P7 decoder). Profile 8 RPUs
// stay: the base layer is self-contained and DV clients can render it.
func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int) []string {
func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagDVSampleEntry bool) []string {
args := []string{
"-nostdin",
"-hide_banner",
@@ -128,6 +160,14 @@ func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcod
if dvProfile == 7 {
args = append(args, "-bsf:v", "dovi_rpu=strip=1")
} else if (dvProfile == 5 || dvProfile == 8) && tagDVSampleEntry {
// FFmpeg carries the DOVI configuration record into MP4 but otherwise
// labels copied HEVC as hev1. Media3 keys decoder selection from the
// sample entry, so retain an explicit Dolby Vision tag as well.
// dvhe keeps FFmpeg's dvvC box; forcing dvh1 makes FFmpeg 7.1 omit it.
// Only the explicit v3 preserve recipe opts in: legacy web/jellycompat
// consumers keep the pre-v3 hev1 labeling their demuxers accept.
args = append(args, "-tag:v", "dvhe")
}
if transcodeAudio {
@@ -150,7 +190,10 @@ func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcod
args = append(args,
"-avoid_negative_ts", "make_zero",
"-f", outputFormat,
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
// delay_moov lets the MP4 muxer inspect the first audio packet before
// writing codec configuration. empty_moov fails immediately for copied
// E-AC-3/Atmos tracks because their frame size is not known at header time.
"-movflags", "frag_keyframe+delay_moov+default_base_moof",
"pipe:1",
)
@@ -160,16 +203,58 @@ func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcod
// StartRemux starts an ffmpeg process that copies codecs to a new container.
// When transcodeAudio is false the command is:
//
// ffmpeg -i {input} -c copy -f {format} -movflags frag_keyframe+empty_moov+default_base_moof pipe:1
// ffmpeg -i {input} -c copy -f {format} -movflags frag_keyframe+delay_moov+default_base_moof pipe:1
//
// When transcodeAudio is true video is copied but audio is transcoded to AAC.
// The caller must call Close() when done to clean up resources.
func StartRemux(ctx context.Context, filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int) (*RemuxSession, error) {
return StartRemuxWithDVMode(ctx, filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, RemuxDVLegacyAutoV3, "")
}
// StartRemuxWithDVMode starts a remux with explicit Dolby Vision behavior.
// ffmpegPath selects the binary to execute (empty = process-global discovery);
// v3 callers must pass the configured playback path so the strip capability
// promised by the planner's probe holds for the binary that actually runs.
func StartRemuxWithDVMode(ctx context.Context, filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, mode RemuxDVMode, ffmpegPath string) (*RemuxSession, error) {
ctx, cancel := context.WithCancel(ctx)
bin := ffmpegBinary()
args := buildRemuxArgs(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex,
remuxDVProfile(dvProfile, supportsDoviRPUFilter()))
bin := ResolveFFmpegPath(ffmpegPath)
effectiveProfile := dvProfile
tagDVSampleEntry := false
switch mode {
case "", RemuxDVLegacyAutoV3:
effectiveProfile = remuxDVProfile(dvProfile, supportsDoviRPUFilter(bin))
case RemuxDVStripToHDR10V3:
if dvProfile != 7 && dvProfile != 8 {
cancel()
return nil, fmt.Errorf("Dolby Vision HDR10 strip requires profile 7 or 8")
}
if !supportsDoviRPUFilter(bin) {
cancel()
return nil, fmt.Errorf("Dolby Vision HDR10 remux requires the dovi_rpu bitstream filter")
}
// buildRemuxArgs uses profile 7 as the explicit strip sentinel; the
// filter is equally required for a compatible profile 8 base layer.
effectiveProfile = 7
case RemuxDVPreserveV3:
if dvProfile == 7 {
// The remux maps only the base-layer stream, so dual-layer P7
// cannot be preserved: the EL is dropped and its RPUs would
// dangle. Callers must strip to HDR10 or transcode instead.
cancel()
return nil, fmt.Errorf("Dolby Vision profile 7 cannot be preserved in a progressive remux")
}
tagDVSampleEntry = true
case RemuxDVRejectP7V3:
if dvProfile == 7 {
cancel()
return nil, fmt.Errorf("profile 7 remux is not eligible")
}
default:
cancel()
return nil, fmt.Errorf("unknown remux Dolby Vision mode %q", mode)
}
args := buildRemuxArgs(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, effectiveProfile, tagDVSampleEntry)
cmd := exec.CommandContext(ctx, bin, args...)
stdout, err := cmd.StdoutPipe()
@@ -226,6 +311,12 @@ func containerMIME(format string) string {
// total size is not known in advance.
// When transcodeAudio is true, audio is transcoded to AAC while video is copied.
func ServeRemux(w http.ResponseWriter, r *http.Request, filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int) error {
return ServeRemuxWithDVMode(w, r, filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, RemuxDVLegacyAutoV3, "")
}
// ServeRemuxWithDVMode streams an explicitly declared Dolby Vision recipe.
// ffmpegPath selects the binary to execute (empty = process-global discovery).
func ServeRemuxWithDVMode(w http.ResponseWriter, r *http.Request, filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, mode RemuxDVMode, ffmpegPath string) error {
// Remux output streams for the length of the title; roll the write
// deadline with progress instead of the server's absolute WriteTimeout.
w = httpstream.NewRollingDeadlineWriter(w)
@@ -241,7 +332,7 @@ func ServeRemux(w http.ResponseWriter, r *http.Request, filePath, outputFormat s
return err
}
session, err := StartRemux(r.Context(), filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile)
session, err := StartRemuxWithDVMode(r.Context(), filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, mode, ffmpegPath)
if err != nil {
http.Error(w, "failed to start remux", http.StatusInternalServerError)
return err
+46 -3
View File
@@ -19,9 +19,9 @@ func argsContainPair(args []string, a, b string) bool {
// Stripping the RPUs yields a clean HDR10 stream — both a correctness fix and
// the Apple-parity fallback presentation for devices without a P7 decoder.
func TestBuildRemuxArgsStripsDolbyVisionRPUForProfile7(t *testing.T) {
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7)
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false)
if !argsContainPair(args, "-bsf:v", "dovi_rpu=strip=1") {
t.Fatalf("profile 7 remux must strip DV RPUs, args=%v", strings.Join(args, " "))
t.Fatalf("profile 7 remux must strip DV RPUs from the base layer, args=%v", strings.Join(args, " "))
}
}
@@ -48,9 +48,52 @@ func TestRemuxDVProfileFallsBackWithoutFilterSupport(t *testing.T) {
// enhancement layer and DV-capable clients can render it. Never strip.
func TestBuildRemuxArgsKeepsRPUForProfile8AndPlainFiles(t *testing.T) {
for _, profile := range []int{0, 5, 8} {
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile)
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, false)
if argsContainPair(args, "-bsf:v", "dovi_rpu=strip=1") {
t.Fatalf("profile %d remux must not strip DV RPUs, args=%v", profile, strings.Join(args, " "))
}
}
}
// The DV sample entry is an explicit opt-in for the v3 preserve recipe:
// Media3 keys decoder selection from it, but legacy web/jellycompat consumers
// rely on the pre-v3 hev1 labeling their demuxers accept.
func TestBuildRemuxArgsTagsPreservedDolbyVisionOnlyWhenRequested(t *testing.T) {
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, true)
if !argsContainPair(args, "-tag:v", "dvhe") {
t.Fatalf("preserved Dolby Vision must retain a DV sample entry, args=%v", strings.Join(args, " "))
}
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, false)
if argsContainPair(legacy, "-tag:v", "dvhe") {
t.Fatalf("legacy remux consumers must keep hev1 labeling, args=%v", strings.Join(legacy, " "))
}
stripped := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false)
if argsContainPair(stripped, "-tag:v", "dvhe") {
t.Fatalf("HDR10 fallback must not retain a DV sample entry, args=%v", strings.Join(stripped, " "))
}
}
// Dual-layer Profile 7 cannot be preserved by a base-layer-only remux; the
// explicit preserve recipe must fail fast instead of emitting a stream with
// dangling RPUs and no enhancement layer.
func TestStartRemuxRejectsPreservedProfile7(t *testing.T) {
if _, err := StartRemuxWithDVMode(t.Context(), "/nonexistent.mkv", "mp4", 0, false, -1, 7, RemuxDVPreserveV3, ""); err == nil {
t.Fatal("preserve mode accepted a profile 7 source")
}
}
// An unknown mode must fail for every profile, not only Profile 7 sources.
func TestStartRemuxRejectsUnknownModeForAllProfiles(t *testing.T) {
for _, profile := range []int{0, 5, 7, 8} {
if _, err := StartRemuxWithDVMode(t.Context(), "/nonexistent.mkv", "mp4", 0, false, -1, profile, RemuxDVMode("bogus"), ""); err == nil {
t.Fatalf("unknown remux DV mode accepted for profile %d", profile)
}
}
}
func TestBuildRemuxArgsDelaysMoovForCopiedAtmosConfiguration(t *testing.T) {
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, false)
if !argsContainPair(args, "-movflags", "frag_keyframe+delay_moov+default_base_moof") {
t.Fatalf("remux must delay moov until copied audio is parsed, args=%v", strings.Join(args, " "))
}
}
+306 -18
View File
@@ -21,14 +21,16 @@ type Session struct {
RequestedMediaFileID int
PlayMethod PlayMethod
BasePlayMethod PlayMethod
TranscodeAudio bool // when true, remux should transcode audio to AAC
TranscodeAudio bool // when true, remux should transcode audio to AAC
RemuxDVMode RemuxDVMode
ClientIP string // resolved client IP for the playback session
ClientName string // reported playback client name, when available
ClientVersion string // reported playback client version, when available
ClientUserAgent string // trimmed request user agent for the playback session
TranscodeNodeURL string // URL of assigned transcode node (empty = local/integrated)
AudioTrackIndex int
TranscodeNodeURL string // URL of assigned transcode node (empty = local/integrated)
TranscodeTransportID string // remote node process identity; empty means session ID
AudioTrackIndex int
StreamBitrateKbps int // currently delivered bitrate, when known
TargetResolution string // requested output resolution for transcodes
@@ -54,26 +56,32 @@ type Session struct {
UpdatedAt time.Time
LastActivityAt time.Time
activeTransportCount int
replacementPlayMethod PlayMethod
streamRevision uint64
}
// SessionStreamState stores the mutable stream-specific details that can
// change after a session is created (audio track, client IP, transcode target,
// and reported bitrate).
type SessionStreamState struct {
PlayMethod PlayMethod
BasePlayMethod PlayMethod
AudioTrackIndex int
TranscodeAudio bool
ClientIP string
ClientName string
ClientVersion string
ClientUserAgent string
StreamBitrateKbps int
TargetResolution string
TargetVideoCodec string
TargetAudioCodec string
TargetBitrateKbps int
TranscodeHWAccel string
PlayMethod PlayMethod
BasePlayMethod PlayMethod
AudioTrackIndex int
TranscodeAudio bool
RemuxDVMode RemuxDVMode
ClientIP string
ClientName string
ClientVersion string
ClientUserAgent string
StreamBitrateKbps int
TargetResolution string
TargetVideoCodec string
TargetAudioCodec string
TargetBitrateKbps int
TranscodeHWAccel string
TranscodeNodeURL string
TranscodeTransportID string
TranscodeRouteSet bool
// Byte-affecting transcode recipe fields preserved so an offloaded restart
// (e.g. audio switch) can rebuild the exact same stream. SubtitleTrackIndex
@@ -85,6 +93,37 @@ type SessionStreamState struct {
SegmentDuration int
}
// SessionReplacement is the complete mutable session state associated with a
// protocol-v3 replacement plan. Position is optional because ordinary failure
// recovery must preserve the player's latest progress while seek recovery
// intentionally moves the authoritative timeline.
type SessionReplacement struct {
EffectiveMediaFileID int
StreamState SessionStreamState
PositionSeconds *float64
IsPaused bool
PreservePaused bool
}
// SessionReplacementRollback is an opaque compare-and-swap token returned by
// ApplyReplacement. It can restore the previous session state only while no
// newer stream or progress mutation has superseded the replacement.
type SessionReplacementRollback struct {
sessionID string
appliedRevision uint64
previousEffectiveMediaFileID int
previousStreamState SessionStreamState
previousPosition float64
previousPaused bool
restoreProgress bool
previousReplacementMethod PlayMethod
}
// ErrSessionReplacementSuperseded means a replacement rollback would overwrite
// a newer session mutation. Callers should terminate the session rather than
// expose state that disagrees with the durable playback plan.
var ErrSessionReplacementSuperseded = errors.New("session replacement was superseded")
type clientInfoContextKey struct{}
// ClientInfo carries best-effort client metadata from request handling into
@@ -533,6 +572,93 @@ func (m *SessionManager) CheckTranscodingAllowed(ctx context.Context, userID int
return transcodingDisabledError(requiresVideoTranscode, !requiresVideoTranscode, limits)
}
// CheckReplacementAllowed applies current user limits and admission policy to
// an in-place protocol-v3 recipe replacement. The existing session is excluded
// from the counts because the replacement inherits its stream slot; a direct
// to transcode change still has to acquire an available transcode slot.
func (m *SessionManager) CheckReplacementAllowed(ctx context.Context, sessionID string, method PlayMethod, transcodeAudio bool) error {
if ctx == nil {
ctx = context.Background()
}
// Bounded CAS: persistent count churn means the user is actively starting
// and stopping sessions; failing closed after a few rounds beats spinning
// with a limit-provider DB call per iteration.
const maxAdmissionRetries = 8
for attempt := 0; attempt < maxAdmissionRetries; attempt++ {
m.mu.Lock()
current, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return ErrSessionNotFound
}
userID := current.UserID
currentMethod := current.PlayMethod
// Exclude the replaced session from both counts instead of decrementing
// the totals: a failed session idle past the liveness grace is already
// absent from the count, and a blind decrement would free a slot that
// belongs to another live session.
otherStreams := m.activeCountExcludingLocked(userID, sessionID)
otherTranscodes := m.transcodeCountExcludingLocked(userID, sessionID)
decider := m.admissionDecider
m.mu.Unlock()
limits, err := m.limitsForUser(ctx, userID)
if err != nil {
return err
}
if err := transcodingDisabledError(method == PlayTranscode, transcodeAudio, limits); err != nil {
return err
}
if decider == nil {
m.mu.Lock()
stillCurrent, stillExists := m.sessions[sessionID]
countsStable := stillExists && stillCurrent.PlayMethod == currentMethod && otherStreams == m.activeCountExcludingLocked(userID, sessionID) && otherTranscodes == m.transcodeCountExcludingLocked(userID, sessionID)
if !countsStable {
m.mu.Unlock()
continue
}
if limits.MaxTranscodes > 0 && method == PlayTranscode && otherTranscodes >= limits.MaxTranscodes {
m.mu.Unlock()
return ErrTooManyTranscodes
}
stillCurrent.replacementPlayMethod = method
m.mu.Unlock()
return nil
}
decision, err := decider(ctx, AdmissionRequest{UserID: userID, Limits: limits, CurrentActiveStreams: otherStreams, CurrentActiveTranscodes: otherTranscodes, RequestedMethod: method, RequiresVideoTranscode: method == PlayTranscode, RequiresAudioTranscode: transcodeAudio})
if err != nil {
// Fail closed, but make an engine outage distinguishable from a
// genuine concurrency-limit denial in the logs.
slog.WarnContext(ctx, "playback replacement admission decider error; denying replacement", "component", "playback",
"user_id", userID, "session", sessionID, "method", method, "error", err)
return ErrPlaybackNotAllowed
}
if !decision.Allowed {
return admissionDenyError(decision.ReasonCode)
}
m.mu.Lock()
stillCurrent, stillExists := m.sessions[sessionID]
countsStable := stillExists && stillCurrent.PlayMethod == currentMethod && otherStreams == m.activeCountExcludingLocked(userID, sessionID) && otherTranscodes == m.transcodeCountExcludingLocked(userID, sessionID)
if countsStable {
stillCurrent.replacementPlayMethod = method
m.mu.Unlock()
return nil
}
m.mu.Unlock()
}
return ErrPlaybackNotAllowed
}
// CancelReplacementReservation releases a protocol-v3 capacity reservation
// after a replacement fails before UpdateStreamState commits its new method.
func (m *SessionManager) CancelReplacementReservation(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
if session := m.sessions[sessionID]; session != nil {
session.replacementPlayMethod = ""
}
}
func transcodingDisabledError(requiresVideoTranscode, requiresAudioTranscode bool, limits SessionLimits) error {
if requiresVideoTranscode && limits.TranscodingDisabled {
return ErrTranscodingDisabled
@@ -555,6 +681,7 @@ func (m *SessionManager) UpdateProgress(sessionID string, position float64, isPa
s.Position = position
s.IsPaused = isPaused
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
@@ -575,6 +702,7 @@ func (m *SessionManager) UpdateAudioTrack(sessionID string, audioTrackIndex int,
if s.PlayMethod != PlayTranscode || method == PlayTranscode {
s.PlayMethod = method
}
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
@@ -591,6 +719,13 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream
return ErrSessionNotFound
}
applySessionStreamStateLocked(s, state)
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
func applySessionStreamStateLocked(s *Session, state SessionStreamState) {
if state.PlayMethod != "" {
s.PlayMethod = state.PlayMethod
}
@@ -599,6 +734,15 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream
}
s.AudioTrackIndex = state.AudioTrackIndex
s.TranscodeAudio = state.TranscodeAudio
if state.TranscodeRouteSet {
// A full v3 route description owns the DV mode outright: a replan from
// a DV strip remux to an SDR source must clear the stale mode or every
// later remux request fails the profile check. Legacy partial updates
// never carry a mode and must not clobber one.
s.RemuxDVMode = state.RemuxDVMode
} else if state.RemuxDVMode != "" {
s.RemuxDVMode = state.RemuxDVMode
}
s.ClientIP = state.ClientIP
if value := normalizeClientMetadataValue(state.ClientName, 128); value != "" {
s.ClientName = value
@@ -615,9 +759,132 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream
s.TargetAudioCodec = state.TargetAudioCodec
s.TargetBitrateKbps = state.TargetBitrateKbps
s.TranscodeHWAccel = state.TranscodeHWAccel
if state.TranscodeRouteSet {
s.TranscodeNodeURL = state.TranscodeNodeURL
s.TranscodeTransportID = state.TranscodeTransportID
}
s.SubtitleTrackIndex = state.SubtitleTrackIndex
s.SubtitleBurnIn = state.SubtitleBurnIn
s.SegmentDuration = state.SegmentDuration
if state.TranscodeRouteSet {
// Only the replacement commit consumes the v3 capacity reservation;
// unrelated legacy stream updates arriving mid-replan must not release
// the slot and let a concurrent admission race past the transcode cap.
s.replacementPlayMethod = ""
}
}
func snapshotSessionStreamStateLocked(s *Session) SessionStreamState {
return SessionStreamState{
PlayMethod: s.PlayMethod,
BasePlayMethod: s.BasePlayMethod,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
RemuxDVMode: s.RemuxDVMode,
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientUserAgent: s.ClientUserAgent,
StreamBitrateKbps: s.StreamBitrateKbps,
TargetResolution: s.TargetResolution,
TargetVideoCodec: s.TargetVideoCodec,
TargetAudioCodec: s.TargetAudioCodec,
TargetBitrateKbps: s.TargetBitrateKbps,
TranscodeHWAccel: s.TranscodeHWAccel,
TranscodeNodeURL: s.TranscodeNodeURL,
TranscodeTransportID: s.TranscodeTransportID,
TranscodeRouteSet: true,
SubtitleTrackIndex: s.SubtitleTrackIndex,
SubtitleBurnIn: s.SubtitleBurnIn,
SegmentDuration: s.SegmentDuration,
}
}
func restoreSessionStreamStateLocked(s *Session, state SessionStreamState) {
s.PlayMethod = state.PlayMethod
s.BasePlayMethod = state.BasePlayMethod
s.AudioTrackIndex = state.AudioTrackIndex
s.TranscodeAudio = state.TranscodeAudio
s.RemuxDVMode = state.RemuxDVMode
s.ClientIP = state.ClientIP
s.ClientName = state.ClientName
s.ClientVersion = state.ClientVersion
s.ClientUserAgent = state.ClientUserAgent
s.StreamBitrateKbps = state.StreamBitrateKbps
s.TargetResolution = state.TargetResolution
s.TargetVideoCodec = state.TargetVideoCodec
s.TargetAudioCodec = state.TargetAudioCodec
s.TargetBitrateKbps = state.TargetBitrateKbps
s.TranscodeHWAccel = state.TranscodeHWAccel
s.TranscodeNodeURL = state.TranscodeNodeURL
s.TranscodeTransportID = state.TranscodeTransportID
s.SubtitleTrackIndex = state.SubtitleTrackIndex
s.SubtitleBurnIn = state.SubtitleBurnIn
s.SegmentDuration = state.SegmentDuration
}
// ApplyReplacement atomically updates every live-session field owned by a
// protocol-v3 plan and returns a CAS rollback token for a later persistence
// failure.
func (m *SessionManager) ApplyReplacement(sessionID string, replacement SessionReplacement) (SessionReplacementRollback, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return SessionReplacementRollback{}, ErrSessionNotFound
}
if replacement.EffectiveMediaFileID <= 0 {
return SessionReplacementRollback{}, errors.New("replacement effective media file id is invalid")
}
rollback := SessionReplacementRollback{
sessionID: sessionID,
previousEffectiveMediaFileID: s.MediaFileID,
previousStreamState: snapshotSessionStreamStateLocked(s),
previousReplacementMethod: s.replacementPlayMethod,
}
if replacement.PositionSeconds != nil {
rollback.previousPosition = s.Position
rollback.previousPaused = s.IsPaused
rollback.restoreProgress = true
}
s.MediaFileID = replacement.EffectiveMediaFileID
applySessionStreamStateLocked(s, replacement.StreamState)
if replacement.PositionSeconds != nil {
s.Position = *replacement.PositionSeconds
if !replacement.PreservePaused {
s.IsPaused = replacement.IsPaused
}
}
s.streamRevision++
rollback.appliedRevision = s.streamRevision
m.touchSessionLocked(s)
return rollback, nil
}
// RollbackReplacement restores the state captured by ApplyReplacement when no
// newer session mutation has superseded it.
func (m *SessionManager) RollbackReplacement(sessionID string, rollback SessionReplacementRollback) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
if rollback.sessionID != sessionID || rollback.appliedRevision == 0 || s.streamRevision != rollback.appliedRevision {
return ErrSessionReplacementSuperseded
}
s.MediaFileID = rollback.previousEffectiveMediaFileID
restoreSessionStreamStateLocked(s, rollback.previousStreamState)
if rollback.restoreProgress {
s.Position = rollback.previousPosition
s.IsPaused = rollback.previousPaused
}
s.replacementPlayMethod = rollback.previousReplacementMethod
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
@@ -633,6 +900,7 @@ func (m *SessionManager) SetTranscodeNodeURL(sessionID, url string) error {
}
s.TranscodeNodeURL = url
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
@@ -651,6 +919,7 @@ func (m *SessionManager) SetEffectiveMediaFileID(sessionID string, fileID int) e
if fileID > 0 {
s.MediaFileID = fileID
}
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
@@ -832,9 +1101,21 @@ func (m *SessionManager) TranscodeCount(userID int) int {
// activeCountLocked counts active sessions for a user. Caller must hold the lock.
func (m *SessionManager) activeCountLocked(userID int) int {
return m.activeCountExcludingLocked(userID, "")
}
// activeCountExcludingLocked counts a user's limit-relevant sessions while
// ignoring one session entirely. Replacement admission uses this instead of
// subtracting one from the total: a failed session awaiting replan is often
// idle past the liveness grace and already absent from the count, so a blind
// decrement would free another session's slot.
func (m *SessionManager) activeCountExcludingLocked(userID int, excludeSessionID string) int {
now := time.Now()
count := 0
for _, s := range m.sessions {
if excludeSessionID != "" && s.ID == excludeSessionID {
continue
}
if s.UserID == userID && m.countsTowardLimitsLocked(s, now) {
count++
}
@@ -844,10 +1125,17 @@ func (m *SessionManager) activeCountLocked(userID int) int {
// transcodeCountLocked counts transcode sessions for a user. Caller must hold the lock.
func (m *SessionManager) transcodeCountLocked(userID int) int {
return m.transcodeCountExcludingLocked(userID, "")
}
func (m *SessionManager) transcodeCountExcludingLocked(userID int, excludeSessionID string) int {
now := time.Now()
count := 0
for _, s := range m.sessions {
if s.UserID == userID && s.PlayMethod == PlayTranscode && m.countsTowardLimitsLocked(s, now) {
if excludeSessionID != "" && s.ID == excludeSessionID {
continue
}
if s.UserID == userID && (s.PlayMethod == PlayTranscode || s.replacementPlayMethod == PlayTranscode) && m.countsTowardLimitsLocked(s, now) {
count++
}
}
+214 -13
View File
@@ -684,6 +684,92 @@ func TestSetEffectiveMediaFileID(t *testing.T) {
}
}
func TestSessionReplacementAppliesAndRollsBackAtomically(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
session, err := manager.StartSessionWithFiles(7, "profile-1", 42, 42, playback.PlayDirect, false)
if err != nil {
t.Fatal(err)
}
if err := manager.UpdateStreamState(session.ID, playback.SessionStreamState{
PlayMethod: playback.PlayDirect,
BasePlayMethod: playback.PlayDirect,
AudioTrackIndex: 0,
TranscodeRouteSet: true,
SubtitleTrackIndex: -1,
StreamBitrateKbps: 8_000,
TranscodeNodeURL: "http://old-node",
TranscodeTransportID: "old-transport",
}); err != nil {
t.Fatal(err)
}
position := 321.5
rollback, err := manager.ApplyReplacement(session.ID, playback.SessionReplacement{
EffectiveMediaFileID: 84,
StreamState: playback.SessionStreamState{
PlayMethod: playback.PlayTranscode,
BasePlayMethod: playback.PlayTranscode,
AudioTrackIndex: 2,
TranscodeAudio: true,
TranscodeRouteSet: true,
SubtitleTrackIndex: 1,
StreamBitrateKbps: 3_500,
TranscodeNodeURL: "http://new-node",
TranscodeTransportID: "new-transport",
},
PositionSeconds: &position,
IsPaused: true,
})
if err != nil {
t.Fatal(err)
}
replaced, err := manager.GetSession(session.ID)
if err != nil {
t.Fatal(err)
}
if replaced.MediaFileID != 84 || replaced.PlayMethod != playback.PlayTranscode || replaced.AudioTrackIndex != 2 ||
replaced.TranscodeNodeURL != "http://new-node" || replaced.Position != position || !replaced.IsPaused {
t.Fatalf("replacement session = %#v", replaced)
}
if err := manager.RollbackReplacement(session.ID, rollback); err != nil {
t.Fatal(err)
}
restored, err := manager.GetSession(session.ID)
if err != nil {
t.Fatal(err)
}
if restored.MediaFileID != 42 || restored.PlayMethod != playback.PlayDirect || restored.AudioTrackIndex != 0 ||
restored.TranscodeNodeURL != "http://old-node" || restored.TranscodeTransportID != "old-transport" ||
restored.Position != 0 || restored.IsPaused {
t.Fatalf("restored session = %#v", restored)
}
}
func TestSessionReplacementRollbackRejectsNewerMutation(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
session, err := manager.StartSession(7, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatal(err)
}
rollback, err := manager.ApplyReplacement(session.ID, playback.SessionReplacement{
EffectiveMediaFileID: 84,
StreamState: playback.SessionStreamState{
PlayMethod: playback.PlayRemux,
BasePlayMethod: playback.PlayRemux,
TranscodeRouteSet: true,
SubtitleTrackIndex: -1,
},
})
if err != nil {
t.Fatal(err)
}
if err := manager.UpdateProgress(session.ID, 10, false); err != nil {
t.Fatal(err)
}
if err := manager.RollbackReplacement(session.ID, rollback); !errors.Is(err, playback.ErrSessionReplacementSuperseded) {
t.Fatalf("rollback error = %v, want ErrSessionReplacementSuperseded", err)
}
}
func TestStartSessionWithFiles(t *testing.T) {
mgr := playback.NewSessionManager(0, 0)
session, err := mgr.StartSessionWithFiles(1, "profile-1", 200, 100, playback.PlayRemux, true)
@@ -713,19 +799,22 @@ func TestUpdateStreamState(t *testing.T) {
}
err = mgr.UpdateStreamState(session.ID, playback.SessionStreamState{
PlayMethod: playback.PlayTranscode,
BasePlayMethod: playback.PlayRemux,
AudioTrackIndex: 2,
TranscodeAudio: true,
ClientIP: "10.0.0.10",
StreamBitrateKbps: 4200,
TargetResolution: "1080p",
TargetVideoCodec: "h264",
TargetAudioCodec: "aac",
TargetBitrateKbps: 4000,
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SegmentDuration: 4,
PlayMethod: playback.PlayTranscode,
BasePlayMethod: playback.PlayRemux,
AudioTrackIndex: 2,
TranscodeAudio: true,
ClientIP: "10.0.0.10",
StreamBitrateKbps: 4200,
TargetResolution: "1080p",
TargetVideoCodec: "h264",
TargetAudioCodec: "aac",
TargetBitrateKbps: 4000,
TranscodeNodeURL: "http://node-1",
TranscodeTransportID: "transport-1",
TranscodeRouteSet: true,
SubtitleTrackIndex: 3,
SubtitleBurnIn: true,
SegmentDuration: 4,
})
if err != nil {
t.Fatalf("UpdateStreamState: %v", err)
@@ -744,6 +833,9 @@ func TestUpdateStreamState(t *testing.T) {
if got.AudioTrackIndex != 2 {
t.Errorf("AudioTrackIndex = %d, want 2", got.AudioTrackIndex)
}
if got.TranscodeNodeURL != "http://node-1" || got.TranscodeTransportID != "transport-1" {
t.Fatalf("transcode route = %q/%q", got.TranscodeNodeURL, got.TranscodeTransportID)
}
if !got.TranscodeAudio {
t.Error("TranscodeAudio = false, want true")
}
@@ -774,6 +866,16 @@ func TestUpdateStreamState(t *testing.T) {
if got.SegmentDuration != 4 {
t.Errorf("SegmentDuration = %d, want 4", got.SegmentDuration)
}
if err := mgr.UpdateStreamState(session.ID, playback.SessionStreamState{TranscodeRouteSet: true}); err != nil {
t.Fatal(err)
}
got, err = mgr.GetSession(session.ID)
if err != nil {
t.Fatal(err)
}
if got.TranscodeNodeURL != "" || got.TranscodeTransportID != "" {
t.Fatalf("cleared transcode route = %q/%q", got.TranscodeNodeURL, got.TranscodeTransportID)
}
}
func TestUpdateAudioTrack_PreservesTranscodeTransportForRemuxBase(t *testing.T) {
@@ -1154,3 +1256,102 @@ func TestSessionManager_CleanExpired_PausedGracePeriod(t *testing.T) {
t.Errorf("paused session should survive with 3x grace period, got: %v", err)
}
}
// A failed transcode session idle past the active grace is already absent from
// the live counts; replacement admission must exclude that session explicitly
// rather than decrement the totals, or the decrement frees a slot that belongs
// to another live transcode.
func TestCheckReplacementAllowedExcludesOnlyTheReplacedSession(t *testing.T) {
sm := playback.NewSessionManager(10, 2)
sm.SetLivenessGracePeriods(25*time.Millisecond, time.Hour)
failed, err := sm.StartSession(1, "profile-1", 100, playback.PlayTranscode, false)
if err != nil {
t.Fatalf("StartSession(failed): %v", err)
}
// Age the failed session past the active grace so it no longer counts.
time.Sleep(60 * time.Millisecond)
for i := 0; i < 2; i++ {
if _, err := sm.StartSession(1, "profile-1", 200+i, playback.PlayTranscode, false); err != nil {
t.Fatalf("StartSession(live %d): %v", i, err)
}
}
err = sm.CheckReplacementAllowed(context.Background(), failed.ID, playback.PlayTranscode, false)
if !errors.Is(err, playback.ErrTooManyTranscodes) {
t.Fatalf("CheckReplacementAllowed = %v, want ErrTooManyTranscodes (both slots are held by live sessions)", err)
}
}
// Legacy stream updates (audio switches, progress-driven state) arriving while
// a v3 replacement is in flight must not release the capacity reservation;
// only the replacement's own route-set commit consumes it.
func TestLegacyStreamUpdateKeepsReplacementReservation(t *testing.T) {
sm := playback.NewSessionManager(10, 1)
session, err := sm.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
if err := sm.CheckReplacementAllowed(context.Background(), session.ID, playback.PlayTranscode, false); err != nil {
t.Fatalf("CheckReplacementAllowed: %v", err)
}
// A legacy partial update does not carry TranscodeRouteSet.
if err := sm.UpdateStreamState(session.ID, playback.SessionStreamState{AudioTrackIndex: 1}); err != nil {
t.Fatalf("UpdateStreamState: %v", err)
}
if _, err := sm.StartSession(1, "profile-1", 200, playback.PlayTranscode, false); !errors.Is(err, playback.ErrTooManyTranscodes) {
t.Fatalf("StartSession(transcode) = %v, want ErrTooManyTranscodes (reservation must survive the legacy update)", err)
}
// The route-set commit consumes the reservation and the slot converts to a
// real transcode, so the cap stays enforced.
if err := sm.UpdateStreamState(session.ID, playback.SessionStreamState{PlayMethod: playback.PlayTranscode, TranscodeRouteSet: true}); err != nil {
t.Fatalf("UpdateStreamState(commit): %v", err)
}
if _, err := sm.StartSession(1, "profile-1", 201, playback.PlayTranscode, false); !errors.Is(err, playback.ErrTooManyTranscodes) {
t.Fatalf("StartSession(transcode) after commit = %v, want ErrTooManyTranscodes", err)
}
}
// A full v3 route description owns RemuxDVMode: a replan that lands on a
// non-DV source must clear a stale strip mode, while legacy partial updates
// must not clobber one.
func TestUpdateStreamStateClearsRemuxDVModeOnRouteSet(t *testing.T) {
sm := playback.NewSessionManager(5, 2)
session, err := sm.StartSession(1, "profile-1", 100, playback.PlayRemux, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
if err := sm.UpdateStreamState(session.ID, playback.SessionStreamState{RemuxDVMode: playback.RemuxDVStripToHDR10V3, TranscodeRouteSet: true}); err != nil {
t.Fatalf("UpdateStreamState(set): %v", err)
}
// Legacy partial update: mode survives.
if err := sm.UpdateStreamState(session.ID, playback.SessionStreamState{AudioTrackIndex: 1}); err != nil {
t.Fatalf("UpdateStreamState(legacy): %v", err)
}
got, err := sm.GetSession(session.ID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.RemuxDVMode != playback.RemuxDVStripToHDR10V3 {
t.Fatalf("RemuxDVMode after legacy update = %q, want strip_to_hdr10", got.RemuxDVMode)
}
// v3 route-set update for a non-DV source: mode clears.
if err := sm.UpdateStreamState(session.ID, playback.SessionStreamState{TranscodeRouteSet: true}); err != nil {
t.Fatalf("UpdateStreamState(clear): %v", err)
}
got, err = sm.GetSession(session.ID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.RemuxDVMode != "" {
t.Fatalf("RemuxDVMode after route-set update = %q, want cleared", got.RemuxDVMode)
}
}
+152
View File
@@ -0,0 +1,152 @@
package playback
import (
"fmt"
"github.com/Silo-Server/silo-server/internal/models"
)
type SubtitlePolicyResultV3 struct {
Decision SubtitleDecisionV3
Claims SubtitleClaimsV3
RequiresBurn bool
SelectedIndex int
TransportIndex int
Codec string
Source string
Terminal *TerminalV3
}
type SubtitleInventoryEntryV3 struct {
CombinedIndex int
Codec string
Source string
}
// ResolveSubtitlePolicyV3 decides how the selected subtitle is delivered when
// the plan executes on the given engine. Renderability is engine-specific, so
// callers must resolve the policy against the engine that will actually run
// the plan rather than assuming the direct engine's capabilities.
func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, transcodeAllowed bool, engine EngineV3, additional []SubtitleInventoryEntryV3) SubtitlePolicyResultV3 {
index := -1
if request.SubtitleTrackIndex != nil {
index = *request.SubtitleTrackIndex
} else if request.SubtitleTrackID != "" {
fileID, kind, ordinal, ok := ParseTrackIDV3(request.SubtitleTrackID)
if !ok || kind != "subtitle" || file == nil || fileID != file.ID {
return subtitleTerminalV3("subtitle_track_invalid", "The selected subtitle identity is invalid.")
}
index = ordinal
}
if index < 0 {
return SubtitlePolicyResultV3{Decision: SubtitleDecisionV3{Mode: SubtitleOffV3}, SelectedIndex: -1, TransportIndex: -1}
}
if file == nil {
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle inventory is unavailable.")
}
codec, source, ok := subtitleCodecAtCombinedIndexV3(file, index, additional)
if !ok {
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle track is unavailable.")
}
trackID := TrackIDV3(file.ID, "subtitle", index)
transportIndex := -1
if source == "embedded" {
transportIndex = index - len(file.ExternalSubtitles)
}
engineCaps := request.ClientPlaybackContext.Engines[string(engine)]
text := isTextSubtitleV3(codec)
ass := IsASS(codec)
clientBitmap := isClientRenderableBitmapSubtitleV3(codec)
burnInBitmap := clientBitmap || normalizeCodecV3(codec) == "dvb_teletext"
if !text && !burnInBitmap {
// Unknown codecs (arib_caption, ...) have no validated client render
// path and no validated burn-in filter; promising a silent burn-in
// would produce a plan the transcoder cannot honor.
return subtitleTerminalV3("subtitle_codec_unsupported", fmt.Sprintf("Subtitle format %s has no validated rendering or burn-in route.", codec))
}
if text {
renderable := source != "embedded" && engineCaps.Subtitles.SidecarText || source == "embedded" && engineCaps.Subtitles.EmbeddedText
if ass && request.SubtitleFidelityPreference == SubtitleFidelityPreserveV3 {
renderable = renderable && engineCaps.Subtitles.ASSStyling && engineCaps.Subtitles.FontAttachments
}
if renderable {
return SubtitlePolicyResultV3{
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
Claims: SubtitleClaimsV3{ASSStylingPreserved: !ass || engineCaps.Subtitles.ASSStyling, Reason: "client_render_supported"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
}
}
if request.SubtitleFidelityPreference == SubtitleFidelityCompatibleV3 {
return SubtitlePolicyResultV3{
Decision: SubtitleDecisionV3{Mode: SubtitleConvertV3, TrackID: trackID},
Claims: SubtitleClaimsV3{Reason: "server_text_conversion"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
}
}
}
// The stream handler raw-serves exactly one bitmap sidecar shape: an
// embedded PGS track as .sup. External/downloaded bitmap tracks go through
// WebVTT conversion (impossible for bitmaps) and embedded DVD/DVB have no
// client-renderable representation at all, so promising a sidecar for
// anything broader publishes an artifact URL that always fails at fetch.
// Everything else falls through to burn-in or its terminal.
if clientBitmap && source == "embedded" && IsPGS(codec) && engineCaps.Subtitles.EmbeddedBitmap {
return SubtitlePolicyResultV3{
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
Claims: SubtitleClaimsV3{BitmapSidecar: true, Reason: "client_bitmap_render_supported"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
}
}
if transcodeAllowed {
if source != "embedded" {
return subtitleTerminalV3("subtitle_burn_in_source_unsupported", "The selected subtitle source cannot be burned in by the installed transport.")
}
return SubtitlePolicyResultV3{
Decision: SubtitleDecisionV3{Mode: SubtitleBurnInV3, TrackID: trackID},
Claims: SubtitleClaimsV3{BitmapOverlay: burnInBitmap, Reason: "server_burn_in_required"},
RequiresBurn: true, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
}
}
return subtitleTerminalV3("subtitle_conversion_unsupported", fmt.Sprintf("Subtitle format %s cannot meet the selected fidelity policy.", codec))
}
func subtitleCodecAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (codec, source string, ok bool) {
if index < len(file.ExternalSubtitles) {
return normalizeCodecV3(file.ExternalSubtitles[index].Format), "external", true
}
embedded := index - len(file.ExternalSubtitles)
if embedded >= 0 && embedded < len(file.SubtitleTracks) {
return normalizeCodecV3(file.SubtitleTracks[embedded].Codec), "embedded", true
}
for _, entry := range additional {
if entry.CombinedIndex == index {
return normalizeCodecV3(entry.Codec), entry.Source, true
}
}
return "", "", false
}
func isTextSubtitleV3(codec string) bool {
switch normalizeCodecV3(codec) {
case "srt", "subrip", "vtt", "webvtt", "ass", "ssa", "mov_text", "tx3g",
"eia_608", "eia608", "cea_608", "cea608":
return true
default:
return false
}
}
func isClientRenderableBitmapSubtitleV3(codec string) bool {
switch normalizeCodecV3(codec) {
// pgssub, dvdsub, and dvbsub are ffmpeg's short aliases for the same
// bitstreams; scanners and older rows record either spelling.
case "pgs", "pgssub", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "dvb_subtitle", "dvbsub", "vobsub":
return true
default:
return false
}
}
func subtitleTerminalV3(reason, message string) SubtitlePolicyResultV3 {
return SubtitlePolicyResultV3{Decision: SubtitleDecisionV3{Mode: SubtitleOffV3}, SelectedIndex: -1, TransportIndex: -1, Terminal: &TerminalV3{Reason: reason, Message: message}}
}
@@ -0,0 +1,118 @@
package playback
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestResolveSubtitlePolicyV3RendersCEA608AsTextArtifact(t *testing.T) {
file := detailedFixtureFileV3()
file.ExternalSubtitles = nil
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "eia_608"}}
req := validStartRequestV3()
index := 0
req.SubtitleTrackIndex = &index
result := ResolveSubtitlePolicyV3(file, req, true, EngineMedia3DirectV3, nil)
if result.Terminal != nil || result.RequiresBurn || result.Decision.Mode != SubtitleRenderV3 {
t.Fatalf("CEA-608 should render as a client-styled text artifact: %#v", result)
}
if result.Claims.Reason != "client_render_supported" || result.Claims.BitmapOverlay {
t.Fatalf("CEA-608 claims = %#v", result.Claims)
}
}
func TestResolveSubtitlePolicyV3DoesNotOfferDVBTeletextAsClientBitmap(t *testing.T) {
file := detailedFixtureFileV3()
file.ExternalSubtitles = nil
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "dvb_teletext"}}
req := validStartRequestV3()
index := 0
req.SubtitleTrackIndex = &index
result := ResolveSubtitlePolicyV3(file, req, true, EngineMedia3DirectV3, nil)
if result.Terminal != nil || !result.RequiresBurn || result.Decision.Mode != SubtitleBurnInV3 {
t.Fatalf("DVB teletext must stay on the server fallback: %#v", result)
}
if !result.Claims.BitmapOverlay {
t.Fatalf("DVB teletext burn-in must retain bitmap overlay semantics: %#v", result.Claims)
}
}
func TestResolveSubtitlePolicyV3UnknownCodecIsExplicitlyUnsupported(t *testing.T) {
file := detailedFixtureFileV3()
file.ExternalSubtitles = nil
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "arib_caption"}}
req := validStartRequestV3()
index := 0
req.SubtitleTrackIndex = &index
for _, transcodeAllowed := range []bool{true, false} {
result := ResolveSubtitlePolicyV3(file, req, transcodeAllowed, EngineMedia3DirectV3, nil)
if result.Terminal == nil || result.Terminal.Reason != "subtitle_codec_unsupported" {
t.Fatalf("transcodeAllowed=%v: unknown codec must be explicitly unsupported, got %#v", transcodeAllowed, result)
}
}
}
func TestResolveSubtitlePolicyV3BurnsFFmpegBitmapAliases(t *testing.T) {
file := detailedFixtureFileV3()
file.ExternalSubtitles = nil
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "dvdsub"}}
req := validStartRequestV3()
index := 0
req.SubtitleTrackIndex = &index
result := ResolveSubtitlePolicyV3(file, req, true, EngineMedia3DirectV3, nil)
if result.Terminal != nil || !result.RequiresBurn || result.Decision.Mode != SubtitleBurnInV3 || !result.Claims.BitmapOverlay {
t.Fatalf("dvdsub alias must burn in like dvd_subtitle: %#v", result)
}
}
func TestPlanPlaybackV3TranscodeUsesHLSEngineSubtitleCapabilities(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
file.SubtitleTracks = []models.SubtitleTrack{{Codec: "srt"}}
req := validStartRequestV3()
// A fixed rung forces the HLS transcode route; the fixture's direct
// engine renders embedded text while the HLS engine cannot.
req.QualityPreference = "1080p"
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
req.ClientPlaybackContext.Features = append(req.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
index := 0
req.SubtitleTrackIndex = &index
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index)
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3()})
if result.Plan == nil || result.Plan.Delivery != DeliveryTranscodeHLSV3 {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if result.Plan.Subtitle.Mode == SubtitleRenderV3 {
t.Fatalf("HLS transcode plan claims client render the HLS engine cannot honor: %#v", result.Plan.Subtitle)
}
if result.Plan.Subtitle.Mode != SubtitleConvertV3 || result.Plan.Claims.Subtitles.Reason != "server_text_conversion" {
t.Fatalf("subtitle decision = %#v claims = %#v", result.Plan.Subtitle, result.Plan.Claims.Subtitles)
}
if result.SubtitleTrackIndex != index {
t.Fatalf("subtitle track index = %d", result.SubtitleTrackIndex)
}
}
func TestClientRenderableBitmapSubtitleV3UsesExactCodecFamilies(t *testing.T) {
for _, codec := range []string{"pgs", "pgssub", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "dvb_subtitle", "dvbsub", "vobsub"} {
if !isClientRenderableBitmapSubtitleV3(codec) {
t.Errorf("expected client-renderable bitmap codec: %s", codec)
}
}
for _, codec := range []string{"dvb_teletext", "hdmv_text_subtitle", "arib_caption", "eia_608"} {
if isClientRenderableBitmapSubtitleV3(codec) {
t.Errorf("must not advertise unsupported client bitmap codec: %s", codec)
}
}
}
+13 -2
View File
@@ -26,6 +26,9 @@ type StreamExtractOpts struct {
// "ass"). Controls whether we copy the stream (for ASS, which carries
// styling) or remux to WebVTT (for everything else).
SourceCodec string
// TargetFormat optionally forces a compatible converted artifact (currently
// WebVTT). Empty preserves the legacy source-driven behavior.
TargetFormat string
// SeekSeconds asks ffmpeg to start demuxing at this position. For
// text-event codecs this is the key win — ffmpeg skips the prefix of
// the container instead of scanning from byte 0 to produce earlier
@@ -133,7 +136,7 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error {
// streamExtractArgs builds the ffmpeg argument list for a streaming
// subtitle extract.
func streamExtractArgs(opts StreamExtractOpts) []string {
outCodec, outFormat := streamExtractOutput(opts.SourceCodec)
outCodec, outFormat := streamExtractOutput(opts.SourceCodec, opts.TargetFormat)
args := []string{
"-hide_banner", "-nostats", "-loglevel", "error",
@@ -249,7 +252,15 @@ func copyAndFlush(dst io.Writer, src io.Reader) error {
// copied into a .sup elementary stream for client-side bitmap rendering
// (libpgs); everything else is transmuxed to WebVTT for direct `<track>`
// consumption.
func streamExtractOutput(codec string) (outCodec, outFormat string) {
func streamExtractOutput(codec string, targetFormat ...string) (outCodec, outFormat string) {
// A forced WebVTT target only applies to text sources: bitmap codecs
// carry no text for ffmpeg's webvtt encoder, so honoring the override
// would build a command that always fails mid-response. Fall through to
// the source-driven mapping instead (handlers reject bitmap-to-vtt
// requests before headers are written).
if len(targetFormat) > 0 && strings.EqualFold(targetFormat[0], "vtt") && !NeedsBurnIn(codec) {
return "webvtt", "webvtt"
}
switch {
case IsASS(codec):
return "copy", "ass"
+24
View File
@@ -229,6 +229,30 @@ func TestPGSWindowRequest(t *testing.T) {
}
}
// A forced "vtt" target applies only to text sources: bitmap codecs carry no
// text for ffmpeg's webvtt encoder, so the override must fall back to the
// source-driven mapping instead of building a command that always fails.
func TestStreamExtractOutput_TargetFormatVTTGatedToTextSources(t *testing.T) {
cases := []struct {
codec string
wantCodec string
wantFormat string
}{
{"subrip", "webvtt", "webvtt"},
{"mov_text", "webvtt", "webvtt"},
{"ass", "webvtt", "webvtt"},
{"pgs", "copy", "sup"},
{"hdmv_pgs_subtitle", "copy", "sup"},
}
for _, tc := range cases {
outCodec, outFormat := streamExtractOutput(tc.codec, "vtt")
if outCodec != tc.wantCodec || outFormat != tc.wantFormat {
t.Errorf("streamExtractOutput(%q, \"vtt\") = (%q, %q), want (%q, %q)",
tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat)
}
}
}
func TestStreamExtractArgs_PGSProducesSup(t *testing.T) {
args := streamExtractArgs(StreamExtractOpts{
InputPath: "/media/movie.mkv",
+36
View File
@@ -3,6 +3,7 @@ package playback
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -235,6 +236,41 @@ func ConvertToVTT(input []byte, fromFormat string) ([]byte, error) {
}
}
// ConvertToVTTWithFFmpeg converts subtitle formats that require a real parser
// (notably ASS/SSA) from in-memory data. It keeps downloaded subtitle
// conversion on the same executable path as external sidecar conversion.
func ConvertToVTTWithFFmpeg(ctx context.Context, input []byte, fromFormat, ffmpegPath string) ([]byte, error) {
if converted, err := ConvertToVTT(input, fromFormat); err == nil {
return converted, nil
}
inputFormat := strings.ToLower(strings.TrimSpace(fromFormat))
if inputFormat == "ssa" {
inputFormat = "ass"
}
if inputFormat == "" {
return nil, errors.New("subtitle format is required")
}
if strings.TrimSpace(ffmpegPath) == "" {
ffmpegPath = "ffmpeg"
}
cmd := exec.CommandContext(ctx, ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-f", inputFormat, "-i", "pipe:0",
"-f", "webvtt", "pipe:1",
)
cmd.Stdin = bytes.NewReader(input)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg subtitle conversion failed: %w (stderr: %s)", err, truncateStderr(stderr.String()))
}
if stdout.Len() == 0 {
return nil, errors.New("ffmpeg produced empty subtitle output")
}
return stdout.Bytes(), nil
}
// srtToVTT converts SRT subtitle content to WebVTT format.
// SRT uses commas for millisecond separators; VTT uses periods.
// VTT requires a "WEBVTT" header.
@@ -0,0 +1,69 @@
[
{
"name": "hls_burn_in_sorted_transformations_and_pcm_mutations",
"plan_id": "plan:fixture",
"delivery": "server_remux_hls",
"stream_protocol": "hls",
"container": "hls",
"video_codec": "hevc",
"audio_codec": "aac",
"width": 3840,
"height": 2160,
"bitrate_kbps": 20000,
"dynamic_range": "hdr10",
"subtitle_mode": "burn_in",
"transformations": [
{"name": "hdr_to_sdr_tonemap", "executor": "server", "recipe_version": "1"},
{"name": "audio_to_aac", "executor": "server", "recipe_version": "1"}
],
"output_route_generation": 7,
"local_mutations": ["transport_reopen", "pcm:truehd:8"],
"expected": "v3:6d4724f01b6ff692"
},
{
"name": "direct_client_dv81_executor_and_version",
"plan_id": "plan:dv81-fixture",
"delivery": "original_http",
"stream_protocol": "http_progressive",
"container": "mkv",
"video_codec": "hevc",
"audio_codec": "truehd",
"width": 3840,
"height": 2160,
"bitrate_kbps": 65000,
"dynamic_range": "dolby_vision",
"subtitle_mode": "off",
"transformations": [
{"name": "client_dv7_to_dv81", "executor": "client", "recipe_version": "1"}
],
"output_route_generation": 9,
"local_mutations": [],
"expected": "v3:2a88b5e686373440"
},
{
"name": "direct_device_quirk_and_runtime_correction_identity",
"plan_id": "plan:quirk",
"delivery": "original_http",
"stream_protocol": "http_progressive",
"container": "mkv",
"video_codec": "hevc",
"audio_codec": "eac3",
"width": 3840,
"height": 2160,
"bitrate_kbps": 60000,
"dynamic_range": "dolby_vision",
"subtitle_mode": "off",
"transformations": [],
"applied_quirks": [
{
"id": "android.fire_tv.dv8_hdr10plus_sei_v1",
"registry_revision": "2026-07-13.1",
"action": "client_runtime_correction"
}
],
"runtime_corrections": ["client_dv8_hdr10plus_sanitizer_v1"],
"output_route_generation": 9,
"local_mutations": [],
"expected": "v3:8d843bfffeb3adc3"
}
]
@@ -0,0 +1,27 @@
{
"protocol_version": 3,
"server_features": ["playback_plan_v3", "media3_only", "playback_route_diagnostics"],
"outcome": "playable",
"session_id": "11111111-1111-4111-8111-111111111111",
"playback_plan": {
"protocol_version": 3,
"plan_id": "plan:golden0001",
"session_id": "11111111-1111-4111-8111-111111111111",
"expires_at": "2030-01-01T00:00:00Z",
"delivery": "original_http",
"engine": "media3_direct",
"stream": {"url": "/stream/11111111-1111-4111-8111-111111111111", "protocol": "http_progressive", "container": "mp4", "mime_type": "video/mp4", "headers": {}, "header_refresh": "session"},
"timeline": {"source_start_seconds": 12.5, "stream_origin_seconds": 0, "player_start_seconds": 12.5, "timeline_offset_seconds": 0, "can_seek_anywhere": true, "seek_restoration": "player_position"},
"selected_tracks": {"audio": {"id": "file:42:audio:0", "index": 0}},
"effective_recipe": {"video_codec": "h264", "audio_codec": "aac", "width": 1920, "height": 1080, "frame_rate": 23.976, "bitrate_kbps": 8000, "dynamic_range": "sdr", "audio_channels": 2, "audio_layout": "stereo"},
"claims": {"video": {"hdr10": false, "hdr10_plus": false, "hlg": false, "dolby_vision": false}, "audio": {"codec": "aac", "passthrough": false, "atmos_preserved": false, "reason": "client_decode_supported"}, "subtitles": {"ass_styling_preserved": false, "bitmap_overlay": false, "bitmap_sidecar": false}},
"subtitle": {"mode": "off"},
"transformations": [],
"degradation_warnings": [],
"decision_reason": "validated_original_playback",
"requested_media_file_id": 42,
"effective_media_file_id": 42,
"source": {"media_file_id": 42, "container": "mp4", "video_codec": "h264", "video_profile": "high", "video_level": 41, "bit_depth": 8, "width": 1920, "height": 1080, "frame_rate": 23.976, "bitrate_kbps": 8000, "dynamic_range": "sdr", "dv_enhancement_layer": "none", "audio_codec": "aac", "audio_channels": 2, "audio_layout": "stereo"},
"subtitle_fidelity_policy": "allow_simplified_rendering"
}
}
@@ -0,0 +1,76 @@
{
"protocol_version": 3,
"operation": "failure_recovery",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"attempted_plan_keys": ["v3:0000000000000001"],
"attempt_count": 1,
"quality_preference": "auto",
"position_seconds": 42.5,
"output_route_generation": 7,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"failure": {
"classification": "network_degraded"
},
"client_capabilities": {
"codecs_video": ["h264"],
"codecs_video_hardware": ["h264"],
"codecs_audio": ["aac"],
"containers": ["mp4"],
"max_resolution": "1080p",
"video_decode": [
{
"codec": "h264",
"profiles": ["high"],
"levels": [41],
"bit_depths": [8],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"features": ["playback_plan_v3", "media3_only", "detailed_decode_capabilities"],
"platform": "android",
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"sdk_int": 35,
"abis": ["arm64-v8a"]
},
"output": {
"output_route_generation": 7
},
"engines": {
"media3_direct": {
"enabled": true,
"supported_on_device": true,
"containers": ["mp4"],
"video_codecs": ["h264"],
"audio_decode_codecs": ["aac"],
"subtitles": {
"embedded_text": true,
"sidecar_text": true
},
"auth_header_refresh": true
}
}
}
}
@@ -0,0 +1,45 @@
{
"protocol_version": 3,
"client_features": ["playback_plan_v3", "media3_only", "detailed_decode_capabilities"],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"output_route_generation": 7,
"metered": false,
"client_capabilities": {
"codecs_video": ["h264"],
"codecs_video_hardware": ["h264"],
"codecs_audio": ["aac"],
"containers": ["mp4"],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [{
"codec": "h264",
"profiles": ["high"],
"levels": [41],
"bit_depths": [8],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}]
},
"client_playback_context": {
"protocol_version": 3,
"features": ["playback_plan_v3", "media3_only", "detailed_decode_capabilities"],
"platform": "android",
"form_factor": "tv",
"app_version": "3.0-test",
"device": {"manufacturer": "NVIDIA", "model": "SHIELD Android TV", "sdk_int": 35, "abis": ["arm64-v8a"]},
"output": {"output_route_generation": 7},
"engines": {
"media3_direct": {"enabled": true, "supported_on_device": true, "containers": ["mp4"], "video_codecs": ["h264"], "audio_decode_codecs": ["aac"], "audio_passthrough_codecs": [], "subtitles": {"embedded_text": true, "sidecar_text": true, "ass_styling": false, "embedded_bitmap": false, "sidecar_bitmap": false, "font_attachments": false}, "features": [], "auth_header_refresh": true, "validated_claims": []}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package playback
import (
"fmt"
"strconv"
"strings"
)
func TrackIDV3(fileID int, kind string, ordinal int) string {
return fmt.Sprintf("file:%d:%s:%d", fileID, kind, ordinal)
}
func ParseTrackIDV3(value string) (fileID int, kind string, ordinal int, ok bool) {
parts := strings.Split(value, ":")
if len(parts) != 4 || parts[0] != "file" || (parts[2] != "audio" && parts[2] != "subtitle") {
return 0, "", 0, false
}
fileID, ok = parseCanonicalTrackNumberV3(parts[1])
if !ok || fileID <= 0 {
return 0, "", 0, false
}
ordinal, ok = parseCanonicalTrackNumberV3(parts[3])
if !ok {
return 0, "", 0, false
}
return fileID, parts[2], ordinal, true
}
// parseCanonicalTrackNumberV3 accepts only the exact decimal form emitted by
// TrackIDV3: digits without a sign and without leading zeros, so every parsed
// identity round-trips byte-for-byte.
func parseCanonicalTrackNumberV3(value string) (int, bool) {
if value == "" || (len(value) > 1 && value[0] == '0') {
return 0, false
}
for _, c := range value {
if c < '0' || c > '9' {
return 0, false
}
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, false
}
return parsed, true
}
+38
View File
@@ -0,0 +1,38 @@
package playback
import "testing"
func TestParseTrackIDV3StrictCanonicalRoundTrip(t *testing.T) {
for _, id := range []string{"file:1:audio:0", "file:42:subtitle:7", "file:10:audio:10", "file:987:subtitle:100"} {
fileID, kind, ordinal, ok := ParseTrackIDV3(id)
if !ok {
t.Fatalf("canonical id rejected: %s", id)
}
if rebuilt := TrackIDV3(fileID, kind, ordinal); rebuilt != id {
t.Fatalf("round trip: parsed %s rebuilt as %s", id, rebuilt)
}
}
}
func TestParseTrackIDV3RejectsNonCanonicalNumerics(t *testing.T) {
invalid := []string{
"file:007:audio:1", // leading zeros in file id
"file:7:audio:01", // leading zeros in ordinal
"file:7:audio:+1", // sign
"file:+7:audio:1", // sign
"file:7:audio:-1", // sign
"file:0:audio:1", // file ids are positive
"file:7:video:1", // unknown kind
"file:7:audio:", // empty ordinal
"file::audio:1", // empty file id
"file:7:audio:1:2", // extra segment
"file:7:audio: 1", // whitespace
"file:7:audio:1e2", // exponent form
"file:99999999999999999999:audio:1", // overflow
}
for _, id := range invalid {
if _, _, _, ok := ParseTrackIDV3(id); ok {
t.Errorf("non-canonical id accepted: %q", id)
}
}
}
+31 -15
View File
@@ -28,21 +28,26 @@ func init() {
// TranscodeOpts holds configuration for an HLS transcode session.
type TranscodeOpts struct {
InputPath string
OutputDir string // e.g., /tmp/silo-transcode/{session_id}/
SessionID string
SourceVideoCodec string
SeekSeconds float64
TargetResolution string // e.g., 1080p, 720p
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
TargetCodecAudio string // e.g., aac
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
InputPath string
OutputDir string // e.g., /tmp/silo-transcode/{session_id}/
// OutputSubdir is the signed, root-relative reconstruction directory. Empty
// retains the legacy flat {session_id} layout.
OutputSubdir string
TranscodeTransportID string
SessionID string
SourceVideoCodec string
VideoBitstreamFilter string // validated copy-mode BSF, e.g. dovi_rpu=strip=1
SeekSeconds float64
TargetResolution string // e.g., 1080p, 720p
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
TargetCodecAudio string // e.g., aac
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
// SubtitleCodec is the probed codec of the burn-in track (e.g. "subrip",
// "hdmv_pgs_subtitle"). Bitmap codecs (PGS/DVD/DVB) select the overlay
// filter_complex pipeline; text codecs use the libass subtitles filter.
@@ -57,6 +62,10 @@ type TranscodeOpts struct {
FFmpegLogSink FFmpegLogSink
}
// DV7ToHDR10BitstreamFilter strips Dolby Vision RPU metadata during a
// copy-mode HLS remux; the enhancement layer is dropped by stream mapping.
const DV7ToHDR10BitstreamFilter = "dovi_rpu=strip=1"
// TranscodeSession manages a running ffmpeg HLS transcode process.
type TranscodeSession struct {
cmd *exec.Cmd
@@ -142,6 +151,10 @@ const (
// StartTranscode launches an ffmpeg process that produces HLS segments.
func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession, error) {
if opts.VideoBitstreamFilter != "" &&
(opts.VideoBitstreamFilter != DV7ToHDR10BitstreamFilter || !strings.EqualFold(opts.TargetCodecVideo, "copy")) {
return nil, fmt.Errorf("unsupported video bitstream filter recipe")
}
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = defaultSegmentDuration
}
@@ -290,6 +303,9 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
// Video codec and encoding settings.
if isVideoCopy {
args = append(args, "-c:v", "copy")
if opts.VideoBitstreamFilter == DV7ToHDR10BitstreamFilter {
args = append(args, "-bsf:v", opts.VideoBitstreamFilter)
}
} else {
args = appendVideoArgs(args, opts)
}
+35
View File
@@ -1,10 +1,28 @@
package playback
import (
"context"
"strings"
"testing"
)
func TestStartTranscodeRejectsUnvalidatedBitstreamFilter(t *testing.T) {
_, err := StartTranscode(context.Background(), TranscodeOpts{
VideoBitstreamFilter: "arbitrary_filter=1",
TargetCodecVideo: "copy",
})
if err == nil {
t.Fatal("unvalidated bitstream filter was accepted")
}
_, err = StartTranscode(context.Background(), TranscodeOpts{
VideoBitstreamFilter: DV7ToHDR10BitstreamFilter,
TargetCodecVideo: "h264",
})
if err == nil {
t.Fatal("DV copy filter was accepted for encoded video")
}
}
func TestBuildFFmpegArgs_QSVDropsSuperfastPreset(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
@@ -66,6 +84,23 @@ func TestBuildFFmpegArgs_CopyVideoFromStartUsesZeroBasedTimestamps(t *testing.T)
}
}
func TestBuildFFmpegArgs_CopyVideoAppliesValidatedBitstreamFilter(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
SessionID: "session-dv7",
TargetCodecVideo: "copy",
TargetCodecAudio: "copy",
VideoBitstreamFilter: DV7ToHDR10BitstreamFilter,
SegmentDuration: 2,
})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-c:v copy -bsf:v dovi_rpu=strip=1") {
t.Fatalf("copy-video args should apply the validated DV bitstream filter: %s", joined)
}
}
func TestBuildFFmpegArgs_CopyVideoResumePreservesSourceTimestamps(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
+14 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@@ -38,7 +39,7 @@ func CleanupOrphanedTranscodeDirs(root string, activeSessionIDs map[string]struc
continue
}
if _, ok := activeSessionIDs[entry.Name()]; ok {
if transcodeDirBelongsToActiveSession(entry.Name(), activeSessionIDs) {
continue
}
@@ -63,3 +64,15 @@ func CleanupOrphanedTranscodeDirs(root string, activeSessionIDs map[string]struc
return removed, nil
}
func transcodeDirBelongsToActiveSession(name string, activeSessionIDs map[string]struct{}) bool {
if _, ok := activeSessionIDs[name]; ok {
return true
}
for sessionID := range activeSessionIDs {
if sessionID != "" && strings.HasPrefix(name, sessionID+"-") {
return true
}
}
return false
}
@@ -0,0 +1,63 @@
package playback
import (
"os"
"path/filepath"
"testing"
)
func TestCleanupOrphanedTranscodeDirsPreservesPlanScopedActiveDirectory(t *testing.T) {
root := t.TempDir()
active := "session-1-plan-abc-generation"
orphan := "session-2-plan-def-generation"
for _, name := range []string{active, orphan} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
removed, err := CleanupOrphanedTranscodeDirs(root, map[string]struct{}{"session-1": {}}, 0)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
if _, err := os.Stat(filepath.Join(root, active)); err != nil {
t.Fatalf("active plan directory was removed: %v", err)
}
if _, err := os.Stat(filepath.Join(root, orphan)); !os.IsNotExist(err) {
t.Fatalf("orphan directory still exists: %v", err)
}
}
// An active session owns a dir only when the name is the session id exactly or
// the id followed by the generation separator ('-', see transportGenerationV3).
// A foreign session whose id merely shares the active id as a raw string
// prefix must not have its dirs retained.
func TestCleanupOrphanedTranscodeDirsRequiresSeparatorBoundary(t *testing.T) {
root := t.TempDir()
keep := []string{"session-1", "session-1-plan-abc"}
reap := []string{"session-10", "session-10-plan-def", "session-1extra"}
for _, name := range append(append([]string{}, keep...), reap...) {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
removed, err := CleanupOrphanedTranscodeDirs(root, map[string]struct{}{"session-1": {}}, 0)
if err != nil {
t.Fatal(err)
}
if removed != len(reap) {
t.Fatalf("removed = %d, want %d", removed, len(reap))
}
for _, name := range keep {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Fatalf("active session directory %q was removed: %v", name, err)
}
}
for _, name := range reap {
if _, err := os.Stat(filepath.Join(root, name)); !os.IsNotExist(err) {
t.Fatalf("foreign directory %q still exists: %v", name, err)
}
}
}
+48 -15
View File
@@ -185,6 +185,26 @@ func (m *TranscodeManager) RegisterTranscodeSession(sessionID string, ts *Transc
m.transcodeMu.Unlock()
}
// SwapTranscodeSession atomically publishes a prepared successor and returns
// the predecessor without closing it. Protocol-v3 replans use plan-scoped
// output directories, so the caller can commit state first, publish the new
// process, and only then reap the old process without either process writing to
// the other's directory.
func (m *TranscodeManager) SwapTranscodeSession(sessionID string, successor *TranscodeSession) *TranscodeSession {
m.transcodeMu.Lock()
predecessor := m.transcodes[sessionID]
m.transcodes[sessionID] = successor
m.transcodeMu.Unlock()
return predecessor
}
// StopRemoteTranscode removes only the remote node process. It deliberately
// leaves the local live-map entry untouched for an atomic remote-to-local v3
// replacement.
func (m *TranscodeManager) StopRemoteTranscode(sessionID, transcodeNodeURL string) {
m.deleteRemoteTranscode(sessionID, transcodeNodeURL)
}
// LockSessionLifecycle acquires the per-session lifecycle mutex and returns a
// release func. Every path that spawns ffmpeg into a session's output directory
// (fresh start, restart, reconstruct) must hold it across "check existing → spawn
@@ -348,20 +368,22 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str
}
s := &Session{
ID: card.SessionID,
UserID: card.UserID,
ProfileID: card.ProfileID,
MediaFileID: card.MediaFileID,
PlayMethod: method,
BasePlayMethod: method,
TranscodeNodeURL: card.TranscodeNodeURL,
AudioTrackIndex: card.AudioTrackIndex,
TranscodeAudio: card.TranscodeAudio,
TargetResolution: card.TargetResolution,
TargetVideoCodec: card.TargetCodecVideo,
TargetAudioCodec: card.TargetCodecAudio,
TargetBitrateKbps: card.TargetBitrateKbps,
TranscodeHWAccel: card.HWAccel,
ID: card.SessionID,
UserID: card.UserID,
ProfileID: card.ProfileID,
MediaFileID: card.MediaFileID,
PlayMethod: method,
BasePlayMethod: method,
TranscodeNodeURL: card.TranscodeNodeURL,
TranscodeTransportID: card.TranscodeTransportID,
AudioTrackIndex: card.AudioTrackIndex,
TranscodeAudio: card.TranscodeAudio,
RemuxDVMode: card.RemuxDVMode,
TargetResolution: card.TargetResolution,
TargetVideoCodec: card.TargetCodecVideo,
TargetAudioCodec: card.TargetCodecAudio,
TargetBitrateKbps: card.TargetBitrateKbps,
TranscodeHWAccel: card.HWAccel,
// Preserve the byte-affecting recipe so an audio switch after a restart
// rebuilds the same stream (subtitles/cadence) instead of dropping them.
SubtitleTrackIndex: card.SubtitleTrackIndex,
@@ -505,7 +527,7 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID
defer release()
cfg := m.runtimeConfig()
outputDir := filepath.Join(cfg.TranscodeDir, sessionID)
outputDir := reconstructionOutputDir(cfg.TranscodeDir, sessionID, card.OutputSubdir)
opts := card.TranscodeOpts(outputDir, cfg.FFmpegPath, m.logSink())
// Re-resolve environment-specific encode knobs from current config so an
// operator config change applies to reconstructed sessions too.
@@ -586,6 +608,17 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID
return transcodeSession
}
func reconstructionOutputDir(root, sessionID, signedSubdir string) string {
outputSubdir := sessionID
if candidate := filepath.Clean(signedSubdir); candidate != "." && !filepath.IsAbs(candidate) && candidate != ".." && !strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
first, _, _ := strings.Cut(candidate, string(filepath.Separator))
if first == sessionID || strings.HasPrefix(first, sessionID+"-") {
outputSubdir = candidate
}
}
return filepath.Join(root, outputSubdir)
}
// MonitorLocalTranscodeExit watches a local ffmpeg process and, on an error exit,
// invokes OnFFmpegCrash so the embedding handler tears down the playback session.
// A clean exit (no error) leaves the segments servable until the client stops.
+87
View File
@@ -0,0 +1,87 @@
package playback
import (
"bytes"
"context"
"os/exec"
"sort"
"time"
)
type TransformationSpecV3 struct {
Name string
RecipeVersion string
Available bool
RequiredCapability string
PromisedDynamicRange string
ValidatedClaims []string
TerminalReason string
}
type TransformationRegistryV3 struct {
entries map[string]TransformationSpecV3
}
func ProbeTransformationRegistryV3(ctx context.Context, ffmpegPath string) *TransformationRegistryV3 {
// Resolve exactly like the execution paths (remux and transcode) so every
// capability advertised here holds for the binary that later runs.
ffmpegPath = ResolveFFmpegPath(ffmpegPath)
bsfCtx, cancelBSF := context.WithTimeout(ctx, 3*time.Second)
bsfs, _ := exec.CommandContext(bsfCtx, ffmpegPath, "-hide_banner", "-bsfs").Output()
cancelBSF()
encoderCtx, cancelEncoders := context.WithTimeout(ctx, 3*time.Second)
encoders, _ := exec.CommandContext(encoderCtx, ffmpegPath, "-hide_banner", "-encoders").Output()
cancelEncoders()
_, ffmpegErr := exec.LookPath(ffmpegPath)
return NewTransformationRegistryV3([]TransformationSpecV3{
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: bytes.Contains(bsfs, []byte("dovi_rpu")), RequiredCapability: "ffmpeg_bsf:dovi_rpu", PromisedDynamicRange: "hdr10", ValidatedClaims: []string{"dolby_vision_metadata_removed", "hdr10_base_layer_preserved", "enhancement_layer_discarded"}, TerminalReason: "dv_conversion_unsupported"},
{Name: "audio_to_aac", RecipeVersion: "1", Available: ffmpegErr == nil && bytes.Contains(encoders, []byte(" aac ")), RequiredCapability: "ffmpeg_encoder:aac", ValidatedClaims: []string{"media3_audio_decode"}, TerminalReason: "audio_conversion_unsupported"},
{Name: "video_to_h264", RecipeVersion: "1", Available: ffmpegErr == nil && h264EncoderAvailableV3(encoders), RequiredCapability: "ffmpeg_encoder:h264", PromisedDynamicRange: "sdr", ValidatedClaims: []string{"media3_h264_decode"}, TerminalReason: "video_conversion_unsupported"},
})
}
// h264EncodersV3 lists every H.264 encoder the transcode pipeline can select
// (see buildTranscodeArgs' hardware ladder in transcode.go); any one of them
// satisfies the video_to_h264 transformation.
var h264EncodersV3 = []string{"libx264", "h264_qsv", "h264_vaapi", "h264_nvenc", "h264_videotoolbox"}
func h264EncoderAvailableV3(encoders []byte) bool {
for _, encoder := range h264EncodersV3 {
if bytes.Contains(encoders, []byte(encoder)) {
return true
}
}
return false
}
func NewTransformationRegistryV3(specs []TransformationSpecV3) *TransformationRegistryV3 {
r := &TransformationRegistryV3{entries: make(map[string]TransformationSpecV3, len(specs))}
for _, spec := range specs {
if spec.Name != "" {
r.entries[spec.Name] = spec
}
}
return r
}
func (r *TransformationRegistryV3) Available(name string) bool {
if r == nil {
return false
}
spec, ok := r.entries[name]
return ok && spec.Available
}
func (r *TransformationRegistryV3) Advertised() []TransformationV3 {
if r == nil {
return nil
}
result := make([]TransformationV3, 0, len(r.entries))
for _, spec := range r.entries {
if spec.Available {
result = append(result, TransformationV3{Name: spec.Name, Executor: "server", RecipeVersion: spec.RecipeVersion, ValidatedClaims: append([]string(nil), spec.ValidatedClaims...)})
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result
}
@@ -0,0 +1,26 @@
package playback
import "testing"
func TestH264EncoderAvailabilityAcceptsAnyPipelineEncoder(t *testing.T) {
cases := []struct {
name string
listing string
want bool
}{
{"software", " V....D libx264 libx264 H.264 / AVC / MPEG-4 AVC", true},
{"qsv", " V..... h264_qsv H.264 / AVC / MPEG-4 AVC (Intel Quick Sync Video acceleration)", true},
{"vaapi", " V..... h264_vaapi H.264/AVC (VAAPI)", true},
{"nvenc", " V....D h264_nvenc NVIDIA NVENC H.264 encoder", true},
{"videotoolbox", " V..... h264_videotoolbox VideoToolbox H.264 Encoder", true},
{"hevc_only", " V..... libx265\n V..... hevc_videotoolbox", false},
{"empty", "", false},
}
for _, value := range cases {
t.Run(value.name, func(t *testing.T) {
if got := h264EncoderAvailableV3([]byte(value.listing)); got != value.want {
t.Fatalf("h264EncoderAvailableV3 = %v, want %v", got, value.want)
}
})
}
}
+18
View File
@@ -0,0 +1,18 @@
package proxy
import (
"testing"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
func TestTranscodeTransportIDFromClaims(t *testing.T) {
claims := &streamtoken.Claims{SessionID: "public-session", TranscodeTransportID: "public-session-plan-a"}
if got := transcodeTransportIDFromClaims(claims); got != claims.TranscodeTransportID {
t.Fatalf("transport id = %q", got)
}
claims.TranscodeTransportID = ""
if got := transcodeTransportIDFromClaims(claims); got != claims.SessionID {
t.Fatalf("legacy transport id = %q", got)
}
}
+16 -3
View File
@@ -169,7 +169,10 @@ func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
seekSeconds = v
}
}
_ = playback.ServeRemux(w, r, claims.MediaPath, "mp4", seekSeconds, claims.TranscodeAudio, claims.AudioTrackIndex, claims.DVProfile)
// Honor the Dolby Vision mode frozen in the token (empty decodes as the
// legacy auto behavior for old tokens), mirroring how the integrated
// server's stream handler serves the same claims.
_ = playback.ServeRemuxWithDVMode(w, r, claims.MediaPath, "mp4", seekSeconds, claims.TranscodeAudio, claims.AudioTrackIndex, claims.DVProfile, playback.RemuxDVMode(claims.RemuxDVMode), s.watcher.Config().Playback.FFmpegPath)
}
func (s *Server) handleTranscodeManifest(w http.ResponseWriter, r *http.Request) {
@@ -178,7 +181,7 @@ func (s *Server) handleTranscodeManifest(w http.ResponseWriter, r *http.Request)
return
}
s.touchTranscodeSession(r, claims)
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+claims.SessionID+"/master.m3u8")
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+transcodeTransportIDFromClaims(claims)+"/master.m3u8")
}
func (s *Server) handleTranscodeSegment(w http.ResponseWriter, r *http.Request) {
@@ -188,7 +191,17 @@ func (s *Server) handleTranscodeSegment(w http.ResponseWriter, r *http.Request)
}
s.touchTranscodeSession(r, claims)
name := chi.URLParam(r, "name")
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+claims.SessionID+"/segment/"+name)
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+transcodeTransportIDFromClaims(claims)+"/segment/"+name)
}
func transcodeTransportIDFromClaims(claims *streamtoken.Claims) string {
if claims != nil && claims.TranscodeTransportID != "" {
return claims.TranscodeTransportID
}
if claims == nil {
return ""
}
return claims.SessionID
}
// touchTranscodeSession keeps HLS sessions visible in the active stream count.
+35 -23
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/models"
)
// ffprobeOutput represents the top-level JSON output from ffprobe.
@@ -175,29 +176,30 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
case "video":
dvProfile := dolbyVisionProfileNumber(s.SideDataList)
track := VideoTrackInfo{
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
Codec: s.CodecName,
DolbyVision: dolbyVisionProfile(s.SideDataList),
DVProfile: dvProfile,
DVBLCompatID: dolbyVisionBLCompatID(s.SideDataList),
DVELPresent: dolbyVisionELPresent(s.SideDataList),
HDR10Plus: hasHDR10Plus(s.SideDataList),
Profile: s.Profile,
Level: s.Level,
Width: s.Width,
Height: s.Height,
AspectRatio: s.DisplayAspectRatio,
Interlaced: isInterlaced(s.FieldOrder),
FrameRate: normalizeFrameRate(s.AvgFrameRate),
Bitrate: parseNumeric(s.BitRate) / 1000,
VideoRange: videoRangeLabel(s),
VideoRangeType: videoRangeType(s),
ColorPrimaries: s.ColorPrimaries,
ColorSpace: s.ColorSpace,
ColorTransfer: s.ColorTransfer,
BitDepth: parseBitDepth(s),
PixelFormat: s.PixFmt,
ReferenceFrames: s.Refs,
Title: firstNonEmpty(s.Tags["title"], s.CodecLongName, strings.ToUpper(s.CodecName)),
Codec: s.CodecName,
DolbyVision: dolbyVisionProfile(s.SideDataList),
DVProfile: dvProfile,
DVBLCompatID: dolbyVisionBLCompatID(s.SideDataList),
DVELPresent: dolbyVisionELPresent(s.SideDataList),
DVEnhancementLayer: dolbyVisionEnhancementLayer(dolbyVisionELPresent(s.SideDataList)),
HDR10Plus: hasHDR10Plus(s.SideDataList),
Profile: s.Profile,
Level: s.Level,
Width: s.Width,
Height: s.Height,
AspectRatio: s.DisplayAspectRatio,
Interlaced: isInterlaced(s.FieldOrder),
FrameRate: normalizeFrameRate(s.AvgFrameRate),
Bitrate: parseNumeric(s.BitRate) / 1000,
VideoRange: videoRangeLabel(s),
VideoRangeType: videoRangeType(s),
ColorPrimaries: s.ColorPrimaries,
ColorSpace: s.ColorSpace,
ColorTransfer: s.ColorTransfer,
BitDepth: models.NormalizeVideoBitDepth(parseBitDepth(s), s.PixFmt, s.Profile),
PixelFormat: s.PixFmt,
ReferenceFrames: s.Refs,
}
pd.VideoTracks = append(pd.VideoTracks, track)
if pd.CodecVideo == "" {
@@ -434,6 +436,16 @@ func dolbyVisionELPresent(sideData []ffprobeSideData) bool {
return false
}
// dolbyVisionEnhancementLayer remains conservative until a libdovi-backed
// analyzer has inspected the RPU mapping. ffprobe can prove that an enhancement
// layer exists, but it cannot distinguish MEL from FEL.
func dolbyVisionEnhancementLayer(present bool) string {
if !present {
return "none"
}
return "unknown"
}
func hasHDR10Plus(sideData []ffprobeSideData) bool {
for _, data := range sideData {
typ := strings.ToLower(data.SideDataType)
+24 -23
View File
@@ -2840,29 +2840,30 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
videoTracks := make([]models.VideoTrack, len(probe.VideoTracks))
for i, vt := range probe.VideoTracks {
videoTracks[i] = models.VideoTrack{
Title: vt.Title,
Codec: vt.Codec,
DolbyVision: vt.DolbyVision,
DVProfile: vt.DVProfile,
DVBLCompatID: vt.DVBLCompatID,
DVELPresent: vt.DVELPresent,
HDR10Plus: vt.HDR10Plus,
Profile: vt.Profile,
Level: vt.Level,
Width: vt.Width,
Height: vt.Height,
AspectRatio: vt.AspectRatio,
Interlaced: vt.Interlaced,
FrameRate: vt.FrameRate,
Bitrate: vt.Bitrate,
VideoRange: vt.VideoRange,
VideoRangeType: vt.VideoRangeType,
ColorPrimaries: vt.ColorPrimaries,
ColorSpace: vt.ColorSpace,
ColorTransfer: vt.ColorTransfer,
BitDepth: vt.BitDepth,
PixelFormat: vt.PixelFormat,
ReferenceFrames: vt.ReferenceFrames,
Title: vt.Title,
Codec: vt.Codec,
DolbyVision: vt.DolbyVision,
DVProfile: vt.DVProfile,
DVBLCompatID: vt.DVBLCompatID,
DVELPresent: vt.DVELPresent,
DVEnhancementLayer: vt.DVEnhancementLayer,
HDR10Plus: vt.HDR10Plus,
Profile: vt.Profile,
Level: vt.Level,
Width: vt.Width,
Height: vt.Height,
AspectRatio: vt.AspectRatio,
Interlaced: vt.Interlaced,
FrameRate: vt.FrameRate,
Bitrate: vt.Bitrate,
VideoRange: vt.VideoRange,
VideoRangeType: vt.VideoRangeType,
ColorPrimaries: vt.ColorPrimaries,
ColorSpace: vt.ColorSpace,
ColorTransfer: vt.ColorTransfer,
BitDepth: vt.BitDepth,
PixelFormat: vt.PixelFormat,
ReferenceFrames: vt.ReferenceFrames,
}
}
mf.VideoTracks = videoTracks
+24 -23
View File
@@ -38,29 +38,30 @@ type ProbeData struct {
// VideoTrackInfo describes a probed video track.
type VideoTrackInfo struct {
Title string
Codec string
DolbyVision string
DVProfile int
DVBLCompatID int
DVELPresent bool
HDR10Plus bool
Profile string
Level int
Width int
Height int
AspectRatio string
Interlaced bool
FrameRate string
Bitrate int
VideoRange string
VideoRangeType string
ColorPrimaries string
ColorSpace string
ColorTransfer string
BitDepth int
PixelFormat string
ReferenceFrames int
Title string
Codec string
DolbyVision string
DVProfile int
DVBLCompatID int
DVELPresent bool
DVEnhancementLayer string
HDR10Plus bool
Profile string
Level int
Width int
Height int
AspectRatio string
Interlaced bool
FrameRate string
Bitrate int
VideoRange string
VideoRangeType string
ColorPrimaries string
ColorSpace string
ColorTransfer string
BitDepth int
PixelFormat string
ReferenceFrames int
}
// AudioTrackInfo describes a probed audio track.
+27 -21
View File
@@ -18,20 +18,24 @@ import (
// are lookup keys re-resolved against the authority on reconstruct; they are
// never trusted on their own.
type Claims struct {
SessionID string `json:"sid"`
MediaPath string `json:"path"`
PlayMethod string `json:"method"`
TranscodeAudio bool `json:"ta,omitempty"`
TranscodeNode string `json:"tnode,omitempty"`
TargetCodec string `json:"tc,omitempty"`
TargetRes string `json:"tres,omitempty"`
AudioCodec string `json:"ac,omitempty"`
AudioChannels int `json:"ach,omitempty"`
AudioTrackIndex int `json:"ati,omitempty"`
SessionID string `json:"sid"`
MediaPath string `json:"path"`
PlayMethod string `json:"method"`
TranscodeAudio bool `json:"ta,omitempty"`
TranscodeNode string `json:"tnode,omitempty"`
TranscodeTransportID string `json:"tid,omitempty"`
TargetCodec string `json:"tc,omitempty"`
TargetRes string `json:"tres,omitempty"`
AudioCodec string `json:"ac,omitempty"`
AudioChannels int `json:"ach,omitempty"`
AudioTrackIndex int `json:"ati,omitempty"`
// DVProfile is the file's Dolby Vision profile (0 = none); remux nodes
// use it to strip dangling profile 7 RPUs. Absent in older tokens, which
// decodes as 0 (no strip — the pre-existing behavior).
DVProfile int `json:"dvp,omitempty"`
// RemuxDVMode freezes whether a Profile 7 remux preserves or strips DV
// metadata. Empty is the legacy auto behavior for old tokens.
RemuxDVMode string `json:"dvm,omitempty"`
// Ownership / authorization lookup keys (re-resolved at reconstruct).
// Not trust assertions.
@@ -42,17 +46,19 @@ type Claims struct {
// Reconstruction recipe — the byte-affecting encode parameters, mirroring the
// former playback.RecipeCard. Zero for direct/remux tokens, which reconstruct
// from identity alone plus the client-supplied position.
SourceVideoCodec string `json:"svc,omitempty"`
SeekSeconds float64 `json:"seek,omitempty"`
SegmentDuration int `json:"segd,omitempty"`
StartSegmentNumber int `json:"ssn,omitempty"`
SubtitleTrackIndex int `json:"sti,omitempty"`
SubtitleBurnIn bool `json:"sbi,omitempty"`
SubtitleCodec string `json:"sbc,omitempty"`
TargetBitrateKbps int `json:"tbr,omitempty"`
TotalDuration float64 `json:"dur,omitempty"`
FastStart bool `json:"fs,omitempty"`
TargetCodecAudio string `json:"tca,omitempty"`
SourceVideoCodec string `json:"svc,omitempty"`
VideoBitstreamFilter string `json:"vbsf,omitempty"`
OutputSubdir string `json:"osd,omitempty"`
SeekSeconds float64 `json:"seek,omitempty"`
SegmentDuration int `json:"segd,omitempty"`
StartSegmentNumber int `json:"ssn,omitempty"`
SubtitleTrackIndex int `json:"sti,omitempty"`
SubtitleBurnIn bool `json:"sbi,omitempty"`
SubtitleCodec string `json:"sbc,omitempty"`
TargetBitrateKbps int `json:"tbr,omitempty"`
TotalDuration float64 `json:"dur,omitempty"`
FastStart bool `json:"fs,omitempty"`
TargetCodecAudio string `json:"tca,omitempty"`
// Recipe staleness hint, bumped on each re-mint after a recipe mutation
// (audio/quality/seek switch). An optional client-side hint only.
+231 -52
View File
@@ -27,22 +27,24 @@ import (
// TranscodeStartRequest is the JSON body for POST /transcode/start.
type TranscodeStartRequest struct {
SessionID string `json:"session_id"`
InputPath string `json:"input_path"`
SourceVideoCodec string `json:"source_video_codec"`
SeekSeconds float64 `json:"seek_seconds"`
StartSegmentNumber int `json:"start_segment_number"`
TargetResolution string `json:"target_resolution"`
TargetCodecVideo string `json:"target_codec_video"`
TargetCodecAudio string `json:"target_codec_audio"`
TargetBitrateKbps int `json:"target_bitrate_kbps"`
SegmentDuration int `json:"segment_duration"`
HWAccel string `json:"hw_accel"`
AudioTrackIndex int `json:"audio_track_index"`
SubtitleTrackIndex int `json:"subtitle_track_index"`
SubtitleBurnIn bool `json:"subtitle_burn_in"`
SubtitleCodec string `json:"subtitle_codec,omitempty"`
TotalDuration float64 `json:"total_duration"`
SessionID string `json:"session_id"`
InputPath string `json:"input_path"`
SourceVideoCodec string `json:"source_video_codec"`
VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"`
SeekSeconds float64 `json:"seek_seconds"`
StartSegmentNumber int `json:"start_segment_number"`
TargetResolution string `json:"target_resolution"`
TargetCodecVideo string `json:"target_codec_video"`
TargetCodecAudio string `json:"target_codec_audio"`
TargetBitrateKbps int `json:"target_bitrate_kbps"`
SegmentDuration int `json:"segment_duration"`
HWAccel string `json:"hw_accel"`
AudioTrackIndex int `json:"audio_track_index"`
SubtitleTrackIndex int `json:"subtitle_track_index"`
SubtitleBurnIn bool `json:"subtitle_burn_in"`
SubtitleCodec string `json:"subtitle_codec,omitempty"`
TotalDuration float64 `json:"total_duration"`
RequireReady bool `json:"require_ready,omitempty"`
}
// TranscodeStartResponse is the JSON response for POST /transcode/start.
@@ -58,12 +60,30 @@ type HealthResponse struct {
ActiveJobs int32 `json:"active_jobs"`
}
// sessionIdleTTL is how long a job may go without a manifest or segment
// request before the idle reaper closes it. Reaping is safe because a client
// that comes back later re-presents its still-valid stream token and the job
// reconstructs seeked to the requested segment; without the reaper, a job
// whose audience vanished (e.g. a v3 replan retired its transport id and a
// stale in-flight token resurrected the old one) encodes to end-of-file for
// nobody.
const sessionIdleTTL = 10 * time.Minute
// sessionReapInterval is how often the idle reaper sweeps for stale jobs.
const sessionReapInterval = time.Minute
// Server is the HTTP handler for transcode mode.
type Server struct {
watcher *nodeconfig.Watcher
tracker *nodesessions.Tracker
ffmpegSink playback.FFmpegLogSink
sessions map[string]*playback.TranscodeSession
// lastAccess records, per registered session id, when a manifest or segment
// request last touched the job (registration counts as the first access).
// Guarded by mu alongside sessions; the idle reaper closes jobs whose entry
// is older than sessionIdleTTL.
lastAccess map[string]time.Time
reaperOnce sync.Once
mu sync.RWMutex
activeJobs atomic.Int32
@@ -149,9 +169,10 @@ func (s *Server) restartSessionLocked(ctx context.Context, sessionID string, ses
// NewServer creates a new transcode server.
func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Server {
s := &Server{
watcher: watcher,
tracker: tracker,
sessions: make(map[string]*playback.TranscodeSession),
watcher: watcher,
tracker: tracker,
sessions: make(map[string]*playback.TranscodeSession),
lastAccess: make(map[string]time.Time),
}
if cfg := watcher.Config(); cfg != nil {
if cleaned, err := playback.CleanupOrphanedTranscodeDirs(cfg.Playback.TranscodeDir, nil, 0); err != nil {
@@ -167,6 +188,127 @@ func (s *Server) SetFFmpegLogSink(sink playback.FFmpegLogSink) {
s.ffmpegSink = sink
}
// noteSessionAccessLocked records an access for a registered job. Callers must
// hold s.mu for writing. Lazily allocates so directly-constructed test servers
// work.
func (s *Server) noteSessionAccessLocked(sessionID string) {
if s.lastAccess == nil {
s.lastAccess = make(map[string]time.Time)
}
s.lastAccess[sessionID] = time.Now()
}
// touchSession refreshes a registered job's idle clock so the reaper spares
// it. Unknown ids are ignored — a reconstruct records its own first access
// when it registers the rebuilt job.
func (s *Server) touchSession(sessionID string) {
s.mu.Lock()
if _, ok := s.sessions[sessionID]; ok {
s.noteSessionAccessLocked(sessionID)
}
s.mu.Unlock()
}
// acquireSessionTouched returns the registered job for sessionID and, when
// found, refreshes its idle clock in the same critical section. Doing both
// under one lock closes the gap where the idle reaper could unregister the
// job between a read-lock lookup and a separate touch, leaving the request
// serving a session whose teardown is already removing its output dir.
func (s *Server) acquireSessionTouched(sessionID string) (*playback.TranscodeSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[sessionID]
if ok {
s.noteSessionAccessLocked(sessionID)
}
return session, ok
}
// startIdleReaper launches the background sweep that closes jobs no client has
// touched for sessionIdleTTL. Called once when the node starts serving;
// subsequent calls are no-ops. The goroutine runs for the process lifetime,
// matching the node's own.
func (s *Server) startIdleReaper() {
s.reaperOnce.Do(func() {
go func() {
ticker := time.NewTicker(sessionReapInterval)
defer ticker.Stop()
for range ticker.C {
s.reapIdleSessions(sessionIdleTTL)
}
}()
})
}
// reapIdleSessions closes and unregisters every job whose last manifest or
// segment access is older than ttl. Registration counts as the first access,
// so a job still waiting on its manifest (the RequireReady flow) is never
// reaped mid-wait. Candidates are collected under the map lock, then each is
// re-validated and torn down under its per-session lifecycle lock: Close
// removes the output dir, and without that lock it could race a token
// reconstruct and wipe the segments a fresh ffmpeg is writing. The session's
// recipe is deliberately kept — an idle reap is not a client stop, and a
// still-valid token must be able to reconstruct on the next hit.
func (s *Server) reapIdleSessions(ttl time.Duration) {
cutoff := time.Now().Add(-ttl)
type idleJob struct {
id string
session *playback.TranscodeSession
}
var candidates []idleJob
s.mu.Lock()
for id, session := range s.sessions {
last, ok := s.lastAccess[id]
if !ok {
// Untracked registration (shouldn't happen): start its idle clock
// now rather than closing a job that may be actively serving.
s.noteSessionAccessLocked(id)
continue
}
if last.Before(cutoff) {
candidates = append(candidates, idleJob{id: id, session: session})
}
}
s.mu.Unlock()
for _, c := range candidates {
s.reapSession(c.id, c.session, cutoff)
}
}
// reapSession tears down one idle job under the per-session lifecycle lock so
// its Close can never overlap a start or reconstruct spawning a new ffmpeg
// into the same output dir. Ownership and idleness are re-checked under the
// lock; a job touched or replaced since the sweep scan is spared.
func (s *Server) reapSession(sessionID string, session *playback.TranscodeSession, cutoff time.Time) {
unlock := s.lockSessionLifecycle(sessionID)
defer unlock()
s.mu.Lock()
live, ok := s.sessions[sessionID]
if !ok || live != session {
s.mu.Unlock()
return
}
last, tracked := s.lastAccess[sessionID]
if tracked && !last.Before(cutoff) {
s.mu.Unlock()
return
}
delete(s.sessions, sessionID)
delete(s.lastAccess, sessionID)
s.mu.Unlock()
s.activeJobs.Add(-1)
if err := session.Close(); err != nil {
slog.Error("close idle transcode session", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID)
}
if s.tracker != nil {
s.tracker.Remove(context.Background(), sessionID)
}
slog.Info("transcode node reaped idle session", "component", "transcodenode",
"session", sessionID, "playback_session_id", sessionID, "idle_ms", time.Since(last).Milliseconds())
}
// recipeStore reads a remote transcode's reconstruction recipe written by central
// at transcode start. The jellycompat node-hop token is identity-only by design —
// not because a Jellyfin client can't round-trip it, but because the recipe is
@@ -190,6 +332,7 @@ func (s *Server) SetRecipeStore(store recipeStore) {
// Handler returns the chi.Router with all transcode routes.
func (s *Server) Handler() http.Handler {
s.startIdleReaper()
r := chi.NewRouter()
r.Get("/api/v1/health", s.handleHealth)
@@ -215,12 +358,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
})
}
func (s *Server) handleHWCapabilities(w http.ResponseWriter, _ *http.Request) {
func (s *Server) handleHWCapabilities(w http.ResponseWriter, r *http.Request) {
ffmpegPath := ""
if cfg := s.watcher.Config(); cfg != nil {
ffmpegPath = cfg.Playback.FFmpegPath
}
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
info.Transformations = playback.ProbeTransformationRegistryV3(r.Context(), ffmpegPath).Advertised()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
@@ -293,29 +437,30 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
outputDir := filepath.Join(cfg.Playback.TranscodeDir, req.SessionID)
opts := playback.TranscodeOpts{
InputPath: req.InputPath,
OutputDir: outputDir,
SessionID: req.SessionID,
SourceVideoCodec: req.SourceVideoCodec,
SeekSeconds: req.SeekSeconds,
StartSegmentNumber: req.StartSegmentNumber,
TargetResolution: req.TargetResolution,
TargetCodecVideo: req.TargetCodecVideo,
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: cfg.Playback.FFmpegPath,
HWAccel: req.HWAccel,
HWDevice: "",
AudioTrackIndex: req.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
SubtitleCodec: req.SubtitleCodec,
TotalDuration: req.TotalDuration,
FastStart: true,
NodeType: "transcode",
ExecutionMode: "transcode_node",
FFmpegLogSink: s.ffmpegSink,
InputPath: req.InputPath,
OutputDir: outputDir,
SessionID: req.SessionID,
SourceVideoCodec: req.SourceVideoCodec,
VideoBitstreamFilter: req.VideoBitstreamFilter,
SeekSeconds: req.SeekSeconds,
StartSegmentNumber: req.StartSegmentNumber,
TargetResolution: req.TargetResolution,
TargetCodecVideo: req.TargetCodecVideo,
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: cfg.Playback.FFmpegPath,
HWAccel: req.HWAccel,
HWDevice: "",
AudioTrackIndex: req.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
SubtitleCodec: req.SubtitleCodec,
TotalDuration: req.TotalDuration,
FastStart: true,
NodeType: "transcode",
ExecutionMode: "transcode_node",
FFmpegLogSink: s.ffmpegSink,
}
if opts.HWAccel == "" && cfg.Playback.HWAccel != "" {
@@ -332,6 +477,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
if old, ok := s.sessions[req.SessionID]; ok {
delete(s.sessions, req.SessionID)
delete(s.lastAccess, req.SessionID)
s.mu.Unlock()
s.activeJobs.Add(-1)
_ = old.Close()
@@ -356,9 +502,19 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to start transcode", http.StatusInternalServerError)
return
}
if req.RequireReady {
if _, err := session.WaitForManifest(8 * time.Second); err != nil {
_ = session.Close()
unlock()
slog.ErrorContext(r.Context(), "transcode failed readiness check", "component", "transcodenode", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
http.Error(w, "transcode did not become ready", http.StatusInternalServerError)
return
}
}
s.mu.Lock()
s.sessions[req.SessionID] = session
s.noteSessionAccessLocked(req.SessionID)
s.mu.Unlock()
unlock()
s.activeJobs.Add(1)
@@ -415,7 +571,11 @@ func (s *Server) reconstructFromToken(r *http.Request, sessionID string, request
// The token's recipe must be a transcode card for the session id in the URL: a
// mismatch is a forged or stale request, and direct/remux cards carry no encode
// parameters to rebuild. An empty PlayMethod is a transcode card (back-compat).
if card.SessionID != sessionID || (card.PlayMethod != "" && card.PlayMethod != playback.PlayTranscode) {
expectedTransportID := card.SessionID
if card.TranscodeTransportID != "" {
expectedTransportID = card.TranscodeTransportID
}
if expectedTransportID != sessionID || (card.PlayMethod != "" && card.PlayMethod != playback.PlayTranscode) {
return nil
}
// A native token carries the full byte-affecting recipe. The jellycompat node
@@ -486,6 +646,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
cfg := s.watcher.Config()
outputDir := filepath.Join(cfg.Playback.TranscodeDir, sessionID)
opts := card.TranscodeOpts(outputDir, cfg.Playback.FFmpegPath, s.ffmpegSink)
opts.SessionID = sessionID
// Re-resolve environment-specific encode knobs from this node's live config; the
// token deliberately omits HWAccel/HWDevice so an operator change applies on
// rebuild. Run as a transcode node, not integrated (card.TranscodeOpts defaults).
@@ -528,6 +689,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
return existing
}
s.sessions[sessionID] = session
s.noteSessionAccessLocked(sessionID)
s.mu.Unlock()
s.activeJobs.Add(1)
@@ -575,6 +737,15 @@ func (s *Server) acquireReconstructSlot(ctx context.Context) (func(), bool) {
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "session_id")
// Serialize against in-flight starts and reconstructs. A RequireReady
// start registers its job only after the readiness wait; a stop racing
// that wait (the API rolling back a timed-out start) would otherwise miss
// the map and 404, orphaning the just-spawned ffmpeg until the idle
// reaper finds it. Blocking here until the start registers turns that
// miss into a normal teardown.
unlock := s.lockSessionLifecycle(sessionID)
defer unlock()
s.mu.Lock()
session, ok := s.sessions[sessionID]
if !ok {
@@ -583,6 +754,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
return
}
delete(s.sessions, sessionID)
delete(s.lastAccess, sessionID)
s.mu.Unlock()
s.activeJobs.Add(-1)
@@ -611,10 +783,10 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "session_id")
s.mu.RLock()
session, ok := s.sessions[sessionID]
s.mu.RUnlock()
// Lookup and liveness refresh happen atomically so the idle reaper can
// never unregister the job between them and tear down a session this
// request is about to serve from.
session, ok := s.acquireSessionTouched(sessionID)
if !ok {
// Lost the in-memory session (this node restarted): rebuild it from the
// stream token the proxy forwarded. The manifest path carries no segment
@@ -624,6 +796,9 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// A reconstruct that yielded to a concurrently registered winner has
// not recorded this hit; count it so the reaper sees the liveness.
s.touchSession(sessionID)
}
manifest, err := session.BuildPlaybackManifest("segment/", r.URL.RawQuery)
@@ -643,10 +818,10 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "session_id")
name := chi.URLParam(r, "name")
s.mu.RLock()
session, ok := s.sessions[sessionID]
s.mu.RUnlock()
// Lookup and liveness refresh happen atomically so the idle reaper can
// never unregister the job between them and tear down a session this
// request is about to serve from.
session, ok := s.acquireSessionTouched(sessionID)
if !ok {
// Lost the in-memory session (this node restarted): rebuild it from the
// forwarded stream token, seeked to the segment the client is requesting so
@@ -660,6 +835,9 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// A reconstruct that yielded to a concurrently registered winner has
// not recorded this hit; count it so the reaper sees the liveness.
s.touchSession(sessionID)
}
segPath, err := session.GetSegment(name)
@@ -777,6 +955,7 @@ func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
session.Close()
os.RemoveAll(filepath.Join(cfg.Playback.TranscodeDir, id))
delete(s.sessions, id)
delete(s.lastAccess, id)
stopped = append(stopped, id)
}
s.activeJobs.Store(0)
+119
View File
@@ -1,9 +1,13 @@
package transcodenode
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -35,6 +39,38 @@ func newTestServer(t *testing.T) *Server {
}
}
func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) {
server := newTestServer(t)
ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
requestBody, err := json.Marshal(TranscodeStartRequest{
SessionID: "ready-failure-1",
InputPath: "/media/movie.mkv",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
RequireReady: true,
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody))
rr := httptest.NewRecorder()
server.handleStart(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
server.mu.RLock()
_, registered := server.sessions["ready-failure-1"]
server.mu.RUnlock()
if registered {
t.Fatal("failed readiness session was registered")
}
}
func signCard(t *testing.T, card playback.RecipeCard) string {
t.Helper()
tok, err := streamtoken.Sign(card.ToClaims(), testSecret, time.Hour)
@@ -212,6 +248,89 @@ func TestHandleStop_DeletesRecipe(t *testing.T) {
}
}
// The idle reaper must close only jobs whose last access predates the TTL;
// registration counts as an access, so a just-started job (including one still
// waiting on its manifest in the RequireReady flow) is spared. Zero-value
// TranscodeSessions Close without ffmpeg, so this runs without a real spawn.
func TestReapIdleSessions_ClosesOnlyIdleJobs(t *testing.T) {
s := newTestServer(t)
s.tracker = nodesessions.NewTracker(nil, "node-url", "node-name", "transcode")
s.sessions["fresh-1"] = &playback.TranscodeSession{}
s.sessions["stale-1"] = &playback.TranscodeSession{}
s.lastAccess = map[string]time.Time{
"fresh-1": time.Now(),
"stale-1": time.Now().Add(-sessionIdleTTL - time.Minute),
}
s.activeJobs.Store(2)
s.reapIdleSessions(sessionIdleTTL)
s.mu.RLock()
_, freshAlive := s.sessions["fresh-1"]
_, staleAlive := s.sessions["stale-1"]
_, staleTracked := s.lastAccess["stale-1"]
s.mu.RUnlock()
if !freshAlive {
t.Fatal("recently accessed session was reaped")
}
if staleAlive {
t.Fatal("idle session survived the reaper")
}
if staleTracked {
t.Fatal("reaped session's idle clock was not dropped")
}
if got := s.activeJobs.Load(); got != 1 {
t.Fatalf("activeJobs = %d, want 1", got)
}
}
// A registered job with no recorded access (untracked registration) must not
// be closed; the sweep starts its idle clock instead of reaping a job that may
// be actively serving.
func TestReapIdleSessions_StartsClockForUntrackedJob(t *testing.T) {
s := newTestServer(t)
s.sessions["untracked-1"] = &playback.TranscodeSession{}
s.activeJobs.Store(1)
s.reapIdleSessions(sessionIdleTTL)
s.mu.RLock()
_, alive := s.sessions["untracked-1"]
last, tracked := s.lastAccess["untracked-1"]
s.mu.RUnlock()
if !alive {
t.Fatal("untracked session was reaped")
}
if !tracked || last.IsZero() {
t.Fatal("sweep did not start the untracked session's idle clock")
}
if got := s.activeJobs.Load(); got != 1 {
t.Fatalf("activeJobs = %d, want 1", got)
}
}
// touchSession must refresh a registered job's idle clock and ignore ids with
// no live session (a reconstruct records its own first access on register).
func TestTouchSession_RefreshesIdleClock(t *testing.T) {
s := newTestServer(t)
s.sessions["live-1"] = &playback.TranscodeSession{}
stale := time.Now().Add(-sessionIdleTTL - time.Minute)
s.lastAccess = map[string]time.Time{"live-1": stale}
s.touchSession("live-1")
s.touchSession("ghost-1")
s.mu.RLock()
defer s.mu.RUnlock()
if !s.lastAccess["live-1"].After(stale) {
t.Fatal("touch did not refresh the live session's idle clock")
}
if _, ok := s.lastAccess["ghost-1"]; ok {
t.Fatal("touch recorded access for an unregistered session")
}
}
// spawnReconstruct must NOT apply the fast seg×dur resume seek for copy-mode
// cards: copy-mode segments have variable durations, so seg×dur points at the
// wrong source time. The card's original start must stand. Asserting opts off a
+21
View File
@@ -0,0 +1,21 @@
package migrations
import (
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestPlaybackProtocolV3MigrationAcceptsEveryRouteEvent(t *testing.T) {
migrationBytes, err := FS.ReadFile("sql/20260712103000_add_playback_protocol_v3.sql")
if err != nil {
t.Fatalf("read migration: %v", err)
}
migration := string(migrationBytes)
for _, event := range playback.RouteEventNamesV3() {
if !strings.Contains(migration, "'"+event+"'") {
t.Errorf("route event CHECK constraint is missing %q", event)
}
}
}
@@ -0,0 +1,92 @@
-- +goose Up
-- +goose StatementBegin
INSERT INTO server_settings (key, value)
VALUES ('playback.protocol_v3_enabled', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO server_settings (key, value)
VALUES ('playback.protocol_v3_shadow_enabled', 'false')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE playback_v3_attempts (
playback_attempt_id TEXT PRIMARY KEY,
session_id UUID NOT NULL UNIQUE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
requested_media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
effective_media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
current_plan_id TEXT NOT NULL,
current_plan JSONB NOT NULL,
normalized_request JSONB NOT NULL,
request_digest TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (jsonb_typeof(current_plan) = 'object'),
CHECK (jsonb_typeof(normalized_request) = 'object')
);
CREATE INDEX playback_v3_attempts_expiry_idx
ON playback_v3_attempts (expires_at);
CREATE TABLE playback_v3_replans (
session_id UUID NOT NULL REFERENCES playback_v3_attempts(session_id) ON UPDATE CASCADE ON DELETE CASCADE,
replan_request_id TEXT NOT NULL,
request_digest TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
lease_expires_at TIMESTAMPTZ NOT NULL,
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (session_id, replan_request_id),
CHECK (state IN ('active', 'completed')),
CHECK (response IS NULL OR jsonb_typeof(response) = 'object')
);
CREATE INDEX playback_v3_replans_lease_idx
ON playback_v3_replans (state, lease_expires_at);
CREATE TABLE playback_route_events (
id BIGSERIAL PRIMARY KEY,
playback_attempt_id TEXT NOT NULL,
session_id UUID,
plan_id TEXT,
plan_attempt_id TEXT,
plan_attempt_key TEXT,
event TEXT NOT NULL,
failure_classification TEXT,
fallback_reason TEXT,
output_route_generation BIGINT NOT NULL DEFAULT 0,
diagnostics JSONB NOT NULL DEFAULT '{}'::jsonb,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
client_name TEXT,
client_version TEXT,
client_model TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (event IN (
'plan_selected', 'plan_invalidated', 'plan_failed', 'first_frame',
'terminal', 'stopped', 'runtime_correction_applied',
'runtime_correction_succeeded', 'runtime_correction_failed',
'seek_reanchor_requested', 'seek_reanchored'
)),
CHECK (jsonb_typeof(diagnostics) = 'object')
);
CREATE INDEX playback_route_events_attempt_idx
ON playback_route_events (playback_attempt_id, received_at);
CREATE INDEX playback_route_events_release_idx
ON playback_route_events (event, failure_classification, received_at);
-- The hourly retention delete scans by age alone; without this index it is a
-- sequential scan over up to 30 days of telemetry.
CREATE INDEX playback_route_events_received_idx
ON playback_route_events (received_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS playback_route_events;
DROP TABLE IF EXISTS playback_v3_replans;
DROP TABLE IF EXISTS playback_v3_attempts;
DELETE FROM server_settings WHERE key IN ('playback.protocol_v3_enabled', 'playback.protocol_v3_shadow_enabled');
-- +goose StatementEnd
@@ -0,0 +1,93 @@
-- +goose Up
ALTER TABLE playback_v3_attempts
ADD COLUMN current_replan_request_id TEXT NOT NULL DEFAULT '';
ALTER TABLE playback_v3_replans
ADD COLUMN base_replan_request_id TEXT NOT NULL DEFAULT '';
WITH latest_completed AS (
SELECT DISTINCT ON (session_id)
session_id,
replan_request_id
FROM playback_v3_replans
WHERE state = 'completed'
ORDER BY session_id, updated_at DESC, created_at DESC, replan_request_id DESC
)
UPDATE playback_v3_attempts AS attempts
SET current_replan_request_id = latest_completed.replan_request_id
FROM latest_completed
WHERE attempts.session_id = latest_completed.session_id;
UPDATE playback_v3_replans AS candidate
SET base_replan_request_id = COALESCE((
SELECT previous.replan_request_id
FROM playback_v3_replans AS previous
WHERE previous.session_id = candidate.session_id
AND previous.state = 'completed'
AND previous.updated_at <= candidate.created_at
ORDER BY previous.updated_at DESC, previous.created_at DESC, previous.replan_request_id DESC
LIMIT 1
), '');
-- Keep the revision correct during rolling deploys and rollbacks. Older V3
-- binaries still complete playback_v3_replans in the same transaction, so
-- this trigger advances the attempt marker even when their attempt UPDATE
-- does not know about the new column.
-- +goose StatementBegin
CREATE FUNCTION sync_playback_v3_current_replan_request_id()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.state = 'completed' THEN
-- New binaries already write the marker in the same transaction; the
-- IS DISTINCT FROM guard skips a redundant second row version there
-- while still advancing the marker for older binaries.
UPDATE playback_v3_attempts
SET current_replan_request_id = NEW.replan_request_id
WHERE session_id = NEW.session_id
AND current_replan_request_id IS DISTINCT FROM NEW.replan_request_id;
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER playback_v3_replans_sync_attempt_revision
AFTER UPDATE OF state, response ON playback_v3_replans
FOR EACH ROW
EXECUTE FUNCTION sync_playback_v3_current_replan_request_id();
-- +goose StatementBegin
CREATE FUNCTION seed_playback_v3_replan_base_revision()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.base_replan_request_id = '' THEN
SELECT current_replan_request_id
INTO NEW.base_replan_request_id
FROM playback_v3_attempts
WHERE session_id = NEW.session_id;
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER playback_v3_replans_seed_base_revision
BEFORE INSERT ON playback_v3_replans
FOR EACH ROW
EXECUTE FUNCTION seed_playback_v3_replan_base_revision();
-- +goose Down
DROP TRIGGER IF EXISTS playback_v3_replans_seed_base_revision ON playback_v3_replans;
DROP FUNCTION IF EXISTS seed_playback_v3_replan_base_revision();
DROP TRIGGER IF EXISTS playback_v3_replans_sync_attempt_revision ON playback_v3_replans;
DROP FUNCTION IF EXISTS sync_playback_v3_current_replan_request_id();
ALTER TABLE playback_v3_replans
DROP COLUMN IF EXISTS base_replan_request_id;
ALTER TABLE playback_v3_attempts
DROP COLUMN IF EXISTS current_replan_request_id;
+53
View File
@@ -0,0 +1,53 @@
-- Playback protocol v3 release/validation queries.
-- Bind :since to the beginning of the validation window.
-- Route outcomes and failures by client/device.
SELECT event, COALESCE(failure_classification, '') AS failure,
COALESCE(client_name, '') AS client, COALESCE(client_model, '') AS model,
COUNT(*) AS events
FROM playback_route_events
WHERE received_at >= :since
GROUP BY 1, 2, 3, 4
ORDER BY events DESC;
-- First-frame success rate and latency. Diagnostics are allowlisted at ingest.
SELECT
COUNT(*) FILTER (WHERE event = 'first_frame') AS first_frames,
COUNT(*) FILTER (WHERE event = 'plan_selected') AS selected_plans,
ROUND(100.0 * COUNT(*) FILTER (WHERE event = 'first_frame') /
NULLIF(COUNT(*) FILTER (WHERE event = 'plan_selected'), 0), 2) AS first_frame_pct,
percentile_cont(0.5) WITHIN GROUP
(ORDER BY CASE WHEN diagnostics->>'first_frame_ms' ~ '^[0-9]+([.][0-9]+)?$'
THEN (diagnostics->>'first_frame_ms')::double precision END)
FILTER (WHERE event = 'first_frame') AS first_frame_p50_ms,
percentile_cont(0.95) WITHIN GROUP
(ORDER BY CASE WHEN diagnostics->>'first_frame_ms' ~ '^[0-9]+([.][0-9]+)?$'
THEN (diagnostics->>'first_frame_ms')::double precision END)
FILTER (WHERE event = 'first_frame') AS first_frame_p95_ms
FROM playback_route_events
WHERE received_at >= :since;
-- Replans, terminals, passthrough/PCM recovery and repeated recipe failures.
SELECT
COALESCE(failure_classification, fallback_reason, event) AS classification,
diagnostics->>'pcm_recovery' AS pcm_recovery,
diagnostics->>'retry_outcome' AS retry_outcome,
COUNT(*) AS occurrences,
COUNT(DISTINCT playback_attempt_id) AS attempts
FROM playback_route_events
WHERE received_at >= :since
AND event IN ('plan_invalidated', 'plan_failed', 'terminal')
GROUP BY 1, 2, 3
ORDER BY occurrences DESC;
-- Current recipes and declared degradation transformations.
SELECT
current_plan->>'delivery' AS delivery,
current_plan->'effective_recipe'->>'dynamic_range' AS dynamic_range,
transformation->>'name' AS transformation,
COUNT(*) AS attempts
FROM playback_v3_attempts
LEFT JOIN LATERAL jsonb_array_elements(current_plan->'transformations') transformation ON TRUE
WHERE created_at >= :since
GROUP BY 1, 2, 3
ORDER BY attempts DESC;