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:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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", "*")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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 ©, 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 ©, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": []}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user