diff --git a/docs/superpowers/plans/2026-07-12-playback-protocol-v3.md b/docs/superpowers/plans/2026-07-12-playback-protocol-v3.md new file mode 100644 index 00000000..2ddef63d --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-playback-protocol-v3.md @@ -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. diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 3dd61e28..993b7a0a 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -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 diff --git a/internal/api/handlers/playback_transport.go b/internal/api/handlers/playback_transport.go new file mode 100644 index 00000000..1cd3e4b5 --- /dev/null +++ b/internal/api/handlers/playback_transport.go @@ -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 +} diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go new file mode 100644 index 00000000..89c453dd --- /dev/null +++ b/internal/api/handlers/playback_v3.go @@ -0,0 +1,1904 @@ +package handlers + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/subtitles" + "github.com/Silo-Server/silo-server/internal/transcodenode" +) + +const ( + maxPlaybackV3BodyBytes = 256 << 10 + maxPlaybackV3EventBodyBytes = 32 << 10 + replanLeaseDurationV3 = 15 * time.Second + v3NodeCapabilityTTL = time.Minute +) + +type v3NodeCapabilityCache struct { + transformations []playback.TransformationV3 + expiresAt time.Time +} + +type preparedTransportV3 struct { + url string + nodeURL string + transportID string + commit func() + rollback func() + applySession func() (func() error, error) + afterDurableCommit func() +} + +type transportErrorV3 struct { + reason string + message string + retryable bool + cause error +} + +type v3ReplanLock struct { + mu sync.Mutex + refs int +} + +type v3EventRate struct { + windowStart time.Time + count int +} + +type replacementAdmissionCheckerV3 interface { + CheckReplacementAllowed(context.Context, string, playback.PlayMethod, bool) error +} + +type replacementReservationCancellerV3 interface { + CancelReplacementReservation(string) +} + +type replacementStateManagerV3 interface { + ApplyReplacement(string, playback.SessionReplacement) (playback.SessionReplacementRollback, error) + RollbackReplacement(string, playback.SessionReplacementRollback) error +} + +type sessionReservationReleaserV3 interface { + ReleaseSession(string) +} + +func (e *transportErrorV3) Error() string { + if e.cause != nil { + return e.reason + ": " + e.cause.Error() + } + return e.reason +} + +// v3FlagCacheTTL bounds how stale a v3 feature-flag read may be. Flags stay +// DB-backed so enabling or rolling back needs no process restart, but reading +// them once per start/replan/event would put an uncached settings SELECT on +// every latency-sensitive playback request. +const v3FlagCacheTTL = 5 * time.Second + +type v3FlagCacheEntry struct { + value bool + expiresAt time.Time +} + +func (h *PlaybackHandler) settingFlagCachedV3(ctx context.Context, key string) bool { + if h == nil || h.SettingsRepo == nil { + return false + } + now := time.Now() + h.v3FlagMu.Lock() + entry, ok := h.v3Flags[key] + h.v3FlagMu.Unlock() + if ok && now.Before(entry.expiresAt) { + return entry.value + } + value, err := h.SettingsRepo.Get(ctx, key) + enabled := err == nil && strings.EqualFold(strings.TrimSpace(value), "true") + if err == nil { + h.v3FlagMu.Lock() + if h.v3Flags == nil { + h.v3Flags = make(map[string]v3FlagCacheEntry) + } + h.v3Flags[key] = v3FlagCacheEntry{value: enabled, expiresAt: now.Add(v3FlagCacheTTL)} + h.v3FlagMu.Unlock() + } + return enabled +} + +func (h *PlaybackHandler) protocolV3Enabled(ctx context.Context) bool { + return h.settingFlagCachedV3(ctx, "playback.protocol_v3_enabled") +} + +func (h *PlaybackHandler) transformationRegistryV3(ctx context.Context) *playback.TransformationRegistryV3 { + h.v3RegistryOnce.Do(func() { + h.v3Registry = playback.ProbeTransformationRegistryV3(context.WithoutCancel(ctx), h.playbackConfig().FFmpegPath) + }) + return h.v3Registry +} + +func (h *PlaybackHandler) remoteTransformationsV3(ctx context.Context, nodeURL string) ([]playback.TransformationV3, error) { + now := time.Now() + h.v3NodeCapabilitiesMu.Lock() + entry, ok := h.v3NodeCapabilities[nodeURL] + h.v3NodeCapabilitiesMu.Unlock() + if ok && now.Before(entry.expiresAt) { + return append([]playback.TransformationV3(nil), entry.transformations...), nil + } + + info, err := fetchRemoteTranscodeCapabilities(ctx, nodeURL, h.JWTSecret) + if err != nil { + return nil, err + } + entry = v3NodeCapabilityCache{ + transformations: append([]playback.TransformationV3(nil), info.Transformations...), + expiresAt: now.Add(v3NodeCapabilityTTL), + } + h.v3NodeCapabilitiesMu.Lock() + if h.v3NodeCapabilities == nil { + h.v3NodeCapabilities = make(map[string]v3NodeCapabilityCache) + } + h.v3NodeCapabilities[nodeURL] = entry + h.v3NodeCapabilitiesMu.Unlock() + return append([]playback.TransformationV3(nil), entry.transformations...), nil +} + +func validateRemoteTransformationsV3(plan *playback.PlanV3, advertised []playback.TransformationV3) error { + available := make(map[string]string, len(advertised)) + for _, transformation := range advertised { + available[strings.ToLower(strings.TrimSpace(transformation.Name))] = strings.TrimSpace(transformation.RecipeVersion) + } + if plan == nil { + return errors.New("playback plan is unavailable") + } + for _, required := range plan.Transformations { + if strings.EqualFold(required.Executor, "client") { + continue + } + version, ok := available[strings.ToLower(strings.TrimSpace(required.Name))] + if !ok || version != strings.TrimSpace(required.RecipeVersion) { + return fmt.Errorf("transcode node lacks transformation %s@%s", required.Name, required.RecipeVersion) + } + } + return nil +} + +// HandlePlaybackCapabilityV3 reports only transformations that the installed +// runtime has actually probed. The feature flag is read per request so rollback +// does not require a process restart. +func (h *PlaybackHandler) HandlePlaybackCapabilityV3(w http.ResponseWriter, r *http.Request) { + if apimw.GetUserID(r.Context()) == 0 { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + enabled := h.protocolV3Enabled(r.Context()) + response := playback.CapabilityResponseV3{Enabled: enabled, ProtocolVersions: []int{playback.ProtocolV3}} + if !enabled { + response.Features = []string{} + response.Deliveries = []playback.DeliveryV3{} + response.Transformations = []playback.TransformationV3{} + response.Reason = "disabled" + writeJSON(w, http.StatusOK, response) + return + } + response.Features = []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3, playback.FeatureLayoutPassthrough, playback.FeatureRouteDiagnostics, playback.FeatureDeviceQuirksV3, playback.FeatureSeekReanchorV3} + response.Deliveries = []playback.DeliveryV3{playback.DeliveryOriginalHTTPV3, playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3, playback.DeliveryTranscodeHLSV3} + response.Transformations = h.transformationRegistryV3(r.Context()).Advertised() + writeJSON(w, http.StatusOK, response) +} + +func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.Request, body []byte) { + var req playback.StartRequestV3 + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid protocol v3 request body") + return + } + warnings, err := req.NormalizeAndValidate() + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + profileID := apimw.GetProfileID(r.Context()) + if profileID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required") + return + } + if req.ProfileID != profileID { + writeError(w, http.StatusBadRequest, "bad_request", "profile_id must match X-Profile-Id") + return + } + if !h.protocolV3Enabled(r.Context()) { + writeJSON(w, http.StatusCreated, playback.DisabledResponseV3()) + return + } + userID := apimw.GetUserID(r.Context()) + digestBytes := sha256.Sum256(body) + requestDigest := hex.EncodeToString(digestBytes[:]) + if existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID); lookupErr == nil { + if existing.UserID != userID || existing.ProfileID != profileID || existing.RequestedMediaFileID != req.FileID || + (existing.RequestDigest != "" && existing.RequestDigest != requestDigest) { + writeError(w, http.StatusConflict, "playback_attempt_reused", "The playback attempt ID belongs to a different request") + return + } + // The replayed plan is only usable while its session is alive; a dead + // session must surface as a retryable terminal so the client mints a + // fresh attempt instead of replaying a plan it can never stream. + if _, sessionErr := h.sessionMgr.GetSession(existing.SessionID); sessionErr != nil { + writeJSON(w, http.StatusCreated, playback.NewTerminalResponseV3("session_expired", "The playback session for this attempt has ended.", true)) + return + } + writeJSON(w, http.StatusCreated, decisionResponseFromAttemptV3(existing)) + return + } else if !errors.Is(lookupErr, playback.ErrSessionNotFound) { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check playback attempt idempotency") + return + } + requestedFile, err := h.loadAuthorizedFile(r, req.FileID) + if err != nil { + writeV3FileError(w, err) + return + } + requestedFile = h.ensurePlaybackProbe(r.Context(), requestedFile) + audioIndex, err := resolveV3AudioIndex(requestedFile, req.AudioTrackID, req.AudioTrackIndex) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + effectiveFile := requestedFile + settings := h.plannerSettingsV3(r.Context()) + if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil { + writePlaybackFilePreflightError(w, err) + return + } + result := playback.PlanPlaybackV3(playback.PlannerInputV3{ + Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, + AudioTrackIndex: audioIndex, Settings: settings, + Registry: h.transformationRegistryV3(r.Context()), Now: time.Now(), + AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile), + }) + if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && shouldTryAlternateFileV3(req.QualityPreference) { + if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil { + effectiveFile = h.ensurePlaybackProbe(r.Context(), alternate) + audioIndex = remapAudioIndexV3(requestedFile, effectiveFile, audioIndex) + if err := h.remapSubtitleSelectionV3(r.Context(), requestedFile, effectiveFile, &req); err != nil { + writeJSON(w, http.StatusCreated, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false)) + return + } + if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil { + writePlaybackFilePreflightError(w, err) + return + } + result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + } + } + if result.Terminal != nil { + response := playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable) + h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, Event: playback.RouteEventTerminalV3, FallbackReason: result.Terminal.Reason, OutputRouteGeneration: req.OutputRouteGeneration}, UserID: userID, ProfileID: profileID, ClientName: playbackClientInfoFromRequest(r).Name, ClientVersion: playbackClientInfoFromRequest(r).Version, ClientModel: req.ClientPlaybackContext.Device.Model}) + writeJSON(w, http.StatusCreated, response) + return + } + result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, warnings...) + response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigest, requestedFile, effectiveFile, audioIndex, result) + if statusErr != nil { + if statusErr.reason == "internal_error" { + slog.ErrorContext(r.Context(), "protocol v3 start failed", "component", "api", "reason", statusErr.reason, "error", statusErr.cause) + } + writeJSON(w, http.StatusCreated, playback.NewTerminalResponseV3(statusErr.reason, statusErr.message, statusErr.retryable)) + return + } + writeJSON(w, http.StatusCreated, response) +} + +func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) { + if result.Plan == nil { + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."} + } + if checker, ok := h.sessionMgr.(transcodePermissionChecker); ok && (result.PlayMethod == playback.PlayTranscode || result.TranscodeAudio) { + if err := checker.CheckTranscodingAllowed(r.Context(), userID, result.PlayMethod == playback.PlayTranscode); err != nil { + reason := "transcoding_disabled" + if errors.Is(err, playback.ErrAudioTranscodingDisabled) { + reason = "audio_transcoding_disabled" + } + return playback.DecisionResponseV3{}, &transportErrorV3{reason: reason, message: "The selected server adaptation is disabled for this user."} + } + } + clientInfo := playbackClientInfoFromRequest(r) + ctx := playback.WithClientInfo(r.Context(), clientInfo) + var session *playback.Session + var err error + if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok { + session, err = starter.StartSessionWithFilesContext(ctx, userID, profileID, effectiveFile.ID, requestedFile.ID, result.PlayMethod, result.TranscodeAudio) + } else { + session, err = h.sessionMgr.StartSessionWithFiles(userID, profileID, effectiveFile.ID, requestedFile.ID, result.PlayMethod, result.TranscodeAudio) + } + if err != nil { + return playback.DecisionResponseV3{}, sessionStartErrorV3(err) + } + abort := func() { _ = h.stopPlaybackSessionByID(context.WithoutCancel(r.Context()), session.ID, false) } + if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioIndex, result.PlayMethod); err != nil { + abort() + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to select the playback audio track.", cause: err} + } + position := floatOrZeroHandlerV3(req.StartPosition) + if err := h.sessionMgr.UpdateProgress(session.ID, position, false); err != nil { + abort() + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to initialize the playback timeline.", cause: err} + } + session, err = h.sessionMgr.GetSession(session.ID) + if err != nil { + abort() + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err} + } + result.Plan.SessionID = session.ID + transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result) + if transportErr != nil { + abort() + return playback.DecisionResponseV3{}, transportErr + } + result.Plan.Stream.URL = transport.url + if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil { + transport.rollback() + abort() + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err} + } + response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureRouteDiagnostics, playback.FeatureDeviceQuirksV3, playback.FeatureSeekReanchorV3}, Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan} + record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)} + if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil { + transport.rollback() + abort() + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to commit the live playback session.", cause: err} + } + if err := h.PlanStoreV3.SaveAttempt(r.Context(), record); err != nil { + transport.rollback() + abort() + if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) { + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."} + } + if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) { + existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID) + if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID { + // Replaying a concurrent duplicate is only valid while its + // session is alive; otherwise tell the client to mint a new + // attempt rather than hand it a plan it can never stream. + if _, sessionErr := h.sessionMgr.GetSession(existing.SessionID); sessionErr != nil { + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "session_expired", message: "The playback session for this attempt has ended.", retryable: true} + } + return decisionResponseFromAttemptV3(existing), nil + } + } + return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to persist the playback plan.", cause: err} + } + transport.commit() + h.syncSessionsNow(r.Context(), "v3_start") + h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, Event: playback.RouteEventPlanSelectedV3, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputRouteGeneration: req.OutputRouteGeneration}, UserID: userID, ProfileID: profileID, ClientName: clientInfo.Name, ClientVersion: clientInfo.Version, ClientModel: req.ClientPlaybackContext.Device.Model}) + return response, nil +} + +func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) { + if result.Plan.Delivery != playback.DeliveryTranscodeHLSV3 && result.Plan.Delivery != playback.DeliveryRemuxHLSV3 { + return h.prepareIdentityTransportV3(session, result), nil + } + if h.NodePlanner != nil { + plan := h.NodePlanner.PlanSession(session.ID, session.TranscodeNodeURL, true, result.TargetBitrateKbps) + if plan.TranscodeNode != nil { + transformations, err := h.remoteTransformationsV3(r.Context(), plan.TranscodeNode.URL) + if err == nil { + err = validateRemoteTransformationsV3(result.Plan, transformations) + } + if err == nil { + transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan) + if transportErr != nil { + if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok { + releaser.ReleaseSession(session.ID) + } + } + return transport, transportErr + } + slog.WarnContext(r.Context(), "protocol v3 transcode node capability mismatch", "node", plan.TranscodeNode.URL, "error", err) + if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok { + releaser.ReleaseSession(session.ID) + } + if !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) { + return preparedTransportV3{}, &transportErrorV3{reason: "transcode_node_capability_unavailable", message: "No transcode node can execute the selected playback recipe.", retryable: true, cause: err} + } + } + if !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) { + return preparedTransportV3{}, &transportErrorV3{reason: "capacity_unavailable", message: "No transcode node is available and local fallback is disabled.", retryable: true} + } + } + return h.prepareLocalTransportV3(r, session, file, result) +} + +func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3) preparedTransportV3 { + routeSession := *session + routeSession.PlayMethod = result.PlayMethod + routeSession.BasePlayMethod = result.PlayMethod + routeSession.MediaFileID = result.Plan.EffectiveMediaFileID + routeSession.AudioTrackIndex = plannedAudioTrackIndexV3(result, session.AudioTrackIndex) + routeSession.TranscodeAudio = result.TranscodeAudio + routeSession.RemuxDVMode = remuxDVModeForPlanV3(result.Plan) + previousNodeURL := session.TranscodeNodeURL + previousTransportID := remoteTransportID(session) + unlock := h.tm.LockSessionLifecycle(session.ID) + committed := false + streamURL := h.playbackStreamURL(&routeSession) + if result.Plan != nil && result.Plan.Delivery == playback.DeliveryRemuxProgressiveV3 { + configureProgressiveRemuxTimelineV3(result.Plan) + if seek := result.Plan.Timeline.StreamOriginSeconds; seek > 0 { + streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64)) + } + } + return preparedTransportV3{ + url: streamURL, + commit: func() { + if committed { + return + } + committed = true + h.tm.CloseTranscodeSession(session.ID, "") + if previousNodeURL != "" { + h.tm.StopRemoteTranscode(previousTransportID, previousNodeURL) + } + unlock() + }, + rollback: func() { + if committed { + return + } + committed = true + unlock() + }, + } +} + +// A progressive remux is a freshly generated, chunked MP4 response and does +// not implement byte ranges. Its player clock therefore begins at zero at the +// requested source origin, and arbitrary seeks must request another server +// reanchor rather than issuing a Range request against the remux pipe. +func configureProgressiveRemuxTimelineV3(plan *playback.PlanV3) { + if plan == nil { + return + } + origin := plan.Timeline.SourceStartSeconds + plan.Timeline.PlayerStartSeconds = 0 + plan.Timeline.StreamOriginSeconds = origin + plan.Timeline.TimelineOffsetSeconds = origin + plan.Timeline.SeekWindowStartSeconds = &origin + plan.Timeline.SeekWindowEndSeconds = nil + plan.Timeline.CanSeekAnywhere = false + plan.Timeline.SeekRestoration = "source_position" +} + +func appendPlaybackQueryV3(rawURL, key, value string) string { + separator := "?" + if strings.ContainsRune(rawURL, '?') { + separator = "&" + } + return rawURL + separator + key + "=" + value +} + +func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) { + cfg := h.playbackConfig() + if err := os.MkdirAll(cfg.TranscodeDir, 0o755); err != nil { + return preparedTransportV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to prepare the transcode directory.", cause: err} + } + outputSubdir := transportGenerationV3(session.ID, result.Plan.PlanID) + outputDir := filepath.Join(cfg.TranscodeDir, outputSubdir) + videoCodec := result.TargetVideoCodec + if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 { + videoCodec = "copy" + } + seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration)) + unlock := h.tm.LockSessionLifecycle(session.ID) + ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink}) + if err != nil { + unlock() + return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err} + } + if !ts.IsRunning() { + _ = ts.Close() + unlock() + return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "The playback transport exited during startup.", retryable: true} + } + card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, "", ts.Opts()) + url := appendStreamToken(fmt.Sprintf("/playback/transcode/%s/master.m3u8", session.ID), h.signSessionToken(card)) + committed := false + previousNodeURL := session.TranscodeNodeURL + previousTransportID := remoteTransportID(session) + return preparedTransportV3{ + url: url, + commit: func() { + if committed { + return + } + committed = true + previous := h.tm.SwapTranscodeSession(session.ID, ts) + unlock() + if previous != nil && previous != ts { + _ = previous.Close() + } + if previousNodeURL != "" { + h.tm.StopRemoteTranscode(previousTransportID, previousNodeURL) + } + ts.SetRestartHook(func(ctx context.Context) { + h.maybeStartThrottler(ctx, ts) + h.tm.MonitorLocalTranscodeExit(session.ID, ts) + }) + h.maybeStartThrottler(r.Context(), ts) + h.tm.MonitorLocalTranscodeExit(session.ID, ts) + }, + rollback: func() { + if committed { + return + } + committed = true + _ = ts.Close() + unlock() + }, + }, nil +} + +func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan) (preparedTransportV3, *transportErrorV3) { + node := nodePlan.TranscodeNode + transportID := transportGenerationV3(session.ID, result.Plan.PlanID) + videoCodec := result.TargetVideoCodec + if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 { + videoCodec = "copy" + } + seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration)) + req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true} + nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req) + if err != nil { + // A timeout can fire after the node actually started the job; the + // stop is a harmless 404 when it never did, and reaps an orphan + // full-length transcode when it did. + h.tm.StopRemoteTranscode(transportID, node.URL) + return preparedTransportV3{}, &transportErrorV3{reason: "transcode_node_unavailable", message: "The selected transcode node is unavailable.", retryable: true, cause: err} + } + if status != http.StatusAccepted { + h.tm.StopRemoteTranscode(transportID, node.URL) + return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "The selected transcode node rejected the playback transport.", retryable: true} + } + hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel)) + card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, 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, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration}) + url := h.buildProxyManifestURL(card, nodePlan.ProxyNode) + committed := false + previousNodeURL := session.TranscodeNodeURL + previousTransportID := remoteTransportID(session) + unlock := h.tm.LockSessionLifecycle(session.ID) + return preparedTransportV3{url: url, nodeURL: node.URL, transportID: transportID, commit: func() { + if committed { + return + } + committed = true + h.tm.CloseTranscodeSession(session.ID, "") + if previousNodeURL != "" { + h.tm.StopRemoteTranscode(previousTransportID, previousNodeURL) + } + unlock() + }, rollback: func() { + if committed { + return + } + committed = true + h.tm.StopRemoteTranscode(transportID, node.URL) + // The accepted node job is gone; drop the planner reservation too so + // repeated failed starts cannot pin the node's max-job or bandwidth + // budget until the reservation ages out. + if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok { + releaser.ReleaseSession(session.ID) + } + unlock() + }}, nil +} + +func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState { + state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn} + if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) { + state.SegmentDuration = 2 + } + if state.StreamBitrateKbps <= 0 { + state.StreamBitrateKbps = fileBitrateKbps(file) + } + return state +} + +func (h *PlaybackHandler) updateV3SessionState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) error { + return h.sessionMgr.UpdateStreamState(session.ID, h.v3SessionStreamState(ctx, session, file, result, transport)) +} + +func plannedAudioTrackIndexV3(result playback.PlannerResultV3, fallback int) int { + if result.Plan != nil && result.Plan.SelectedTracks.Audio != nil && result.Plan.SelectedTracks.Audio.Index != nil { + return *result.Plan.SelectedTracks.Audio.Index + } + return fallback +} + +func transportGenerationV3(sessionID, planID string) string { + planSuffix := strings.TrimPrefix(planID, "plan:") + if len(planSuffix) > 12 { + planSuffix = planSuffix[:12] + } + return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8] +} + +func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error { + if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) { + return nil + } + var downloaded []subtitles.DownloadedSubtitle + if h.SubtitleRepo != nil { + var err error + downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID) + if err != nil { + return err + } + } + for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) { + if value.Index != selectedIndex { + continue + } + format := strings.ToLower(value.Codec) + mime := subtitleMIMEV3(format) + url := value.URL + if plan.Subtitle.Mode == playback.SubtitleConvertV3 { + format = "vtt" + mime = "text/vtt" + url = forceSubtitleExtensionV3(value.URL, ".vtt") + } + plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds} + return nil + } + return errors.New("selected subtitle artifact is absent from the frozen inventory") +} + +func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, file *models.MediaFile) []playback.SubtitleInventoryEntryV3 { + if h == nil || h.SubtitleRepo == nil || file == nil { + return nil + } + downloaded, err := h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID) + if err != nil { + return nil + } + base := len(file.ExternalSubtitles) + len(file.SubtitleTracks) + result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded)) + for index, value := range downloaded { + result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"}) + } + return result +} + +// HandleReplanPlaybackV3 provides persistent idempotency and preserves the old +// transport until a successor has entered its startup state and the new plan is +// durably committed. +func (h *PlaybackHandler) HandleReplanPlaybackV3(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication and profile are required") + return + } + if !h.protocolV3Enabled(r.Context()) { + writeJSON(w, http.StatusOK, playback.DisabledResponseV3()) + return + } + body, err := readBoundedV3Body(w, r, maxPlaybackV3BodyBytes) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + var req playback.ReplanRequestV3 + if err := json.Unmarshal(body, &req); err != nil || req.Validate() != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid replan request") + return + } + sessionID := chiURLParamV3(r, "session_id") + releaseSlot, err := h.acquireReplanSlotV3(r.Context()) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "replan_capacity_exhausted", "The server is replanning too many sessions; retry shortly") + return + } + defer releaseSlot() + unlockReplan := h.lockReplanV3(sessionID) + defer unlockReplan() + unlockStore, err := h.PlanStoreV3.AcquireSessionLock(r.Context(), sessionID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serialize the replan request") + return + } + defer unlockStore() + record, err := h.PlanStoreV3.GetAttempt(r.Context(), sessionID) + if err != nil { + // A store outage must read as retryable, not as the session being + // gone: clients tear playback down on session_not_found. + if !errors.Is(err, playback.ErrSessionNotFound) { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load the playback attempt") + return + } + writePlaybackSessionNotFound(w) + return + } + if record.UserID != userID || record.ProfileID != profileID { + writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another profile") + return + } + if record.PlaybackAttemptID != req.PlaybackAttemptID { + writeError(w, http.StatusConflict, "stale_playback_plan", "The failed plan is no longer current") + return + } + if _, err := h.sessionMgr.GetSession(sessionID); err != nil { + writePlaybackSessionNotFound(w) + return + } + digestBytes := sha256.Sum256(body) + digest := hex.EncodeToString(digestBytes[:]) + lease, err := h.PlanStoreV3.BeginReplan( + r.Context(), + sessionID, + req.ReplanRequestID, + digest, + record.CurrentReplanRequestID, + time.Now().Add(replanLeaseDurationV3), + ) + if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) { + writeError(w, http.StatusConflict, "idempotency_key_reused", "The replan request ID was reused with different input") + return + } + if errors.Is(err, playback.ErrStaleReplanLeaseV3) { + writeError(w, http.StatusConflict, "stale_playback_plan", "A newer replacement plan is already active") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reserve the replan request") + return + } + if lease.State == playback.ReplanLeaseInFlightV3 { + writeError(w, http.StatusConflict, "replan_in_progress", "An identical replan is still in progress") + return + } + if lease.State == playback.ReplanLeaseCompletedV3 { + if record.CurrentReplanRequestID != req.ReplanRequestID || !completedReplanResponseMatchesAttemptV3(lease.Response, record) { + writeError(w, http.StatusConflict, "stale_playback_plan", "A newer replacement plan is already active") + return + } + if _, err := h.sessionMgr.GetSession(sessionID); err != nil { + writePlaybackSessionNotFound(w) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(lease.Response) + return + } + if record.CurrentPlanID != req.FailedPlanID { + writeError(w, http.StatusConflict, "stale_playback_plan", "The failed plan is no longer current") + return + } + response, updated, transport, replanErr := h.executeReplanV3(r, record, req) + if replanErr != nil { + response = playback.NewTerminalResponseV3(replanErr.reason, replanErr.message, replanErr.retryable) + updated = *record + } + updated.CurrentReplanRequestID = req.ReplanRequestID + encoded, _ := json.Marshal(response) + var rollbackSession func() error + if transport != nil && transport.applySession != nil { + var err error + rollbackSession, err = transport.applySession() + if err != nil { + transport.rollback() + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to commit the live replacement session") + return + } + } + if err := h.PlanStoreV3.CompleteReplan(r.Context(), sessionID, req.ReplanRequestID, record.CurrentReplanRequestID, encoded, updated); err != nil { + rollbackFailed := false + if rollbackSession != nil { + if rollbackErr := rollbackSession(); rollbackErr != nil { + rollbackFailed = true + slog.ErrorContext(r.Context(), "protocol v3 replacement rollback failed", "session", sessionID, "error", rollbackErr) + } + } + if transport != nil { + transport.rollback() + } + if rollbackFailed { + _ = h.stopPlaybackSessionByID(context.WithoutCancel(r.Context()), sessionID, false) + } + if errors.Is(err, playback.ErrReplanSupersededV3) { + writeError(w, http.StatusConflict, "stale_playback_plan", "A newer replacement plan is already active") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to commit the replacement plan") + return + } + if transport != nil { + transport.commit() + if transport.afterDurableCommit != nil { + transport.afterDurableCommit() + } + } + writeJSON(w, http.StatusOK, response) +} + +func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.AttemptRecordV3, req playback.ReplanRequestV3) (playback.DecisionResponseV3, playback.AttemptRecordV3, *preparedTransportV3, *transportErrorV3) { + reservationHeld := false + reservationHandedOff := false + cancelReservation := func() { + if reservationHeld { + if canceller, ok := h.sessionMgr.(replacementReservationCancellerV3); ok { + canceller.CancelReplacementReservation(record.SessionID) + } + reservationHeld = false + } + } + defer func() { + if !reservationHandedOff { + cancelReservation() + } + }() + start := record.NormalizedRequest + operation := req.EffectiveOperation() + seekReanchor := operation == playback.ReplanOperationSeekReanchorV3 + seekFailureRecovery := operation == playback.ReplanOperationSeekFailureRecoveryV3 + seekScopedRecovery := seekReanchor || seekFailureRecovery + intentChange := false + if seekScopedRecovery { + if err := validateSeekRecoveryRequestV3(record, req); err != nil { + reason := "seek_reanchor_intent_mismatch" + if seekFailureRecovery { + reason = "seek_failure_recovery_intent_mismatch" + } + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ + reason: reason, + message: err.Error(), + } + } + // Reconstruct the complete route intent from the durable current attempt. + // A seek request is not an authority boundary for replacing capability or + // device evidence: accepting those fields here could make the same file + // select a materially different route based on request-only claims. + start.FileID = record.EffectiveMediaFileID + start.StartPosition = &req.PositionSeconds + applySelectedTracksToStartV3(&start, record.CurrentPlan.SelectedTracks) + } else { + // Failure replans may omit unchanged tracks. The durable current plan + // holds the authoritative effective-file selections; the normalized + // request can still carry requested-edition identities after an + // alternate-version fallback, and validating those against the + // effective file would reject an otherwise valid replan. Seed from + // the plan first, then overlay the request's explicit changes. + applySelectedTracksToStartV3(&start, record.CurrentPlan.SelectedTracks) + switch req.Failure.Classification { + case "quality_changed": + nextQuality, _ := playback.NormalizeQualityV3(req.QualityPreference) + intentChange = nextQuality != start.QualityPreference + case "audio_track_changed": + intentChange = req.SelectedTracks.Audio != nil && + (req.SelectedTracks.Audio.ID != start.AudioTrackID || !optionalIntEqualV3(req.SelectedTracks.Audio.Index, start.AudioTrackIndex)) + case "subtitle_track_changed": + intentChange = req.SelectedTracks.Subtitle == nil && start.SubtitleTrackIndex != nil || + req.SelectedTracks.Subtitle != nil && + (req.SelectedTracks.Subtitle.ID != start.SubtitleTrackID || !optionalIntEqualV3(req.SelectedTracks.Subtitle.Index, start.SubtitleTrackIndex)) + case "output_route_changed": + intentChange = req.OutputRouteGeneration != start.OutputRouteGeneration + } + // Failure replans use the current effective file. Explicit user/output + // intent changes restart source selection from the requested edition. + start.FileID = record.EffectiveMediaFileID + if intentChange { + start.FileID = record.RequestedMediaFileID + } + start.QualityPreference = req.QualityPreference + start.StartPosition = &req.PositionSeconds + start.OutputRouteGeneration = req.OutputRouteGeneration + start.Metered = req.Metered + start.BandwidthEstimateKbps = copyOptionalIntV3(req.BandwidthEstimateKbps) + start.BandwidthCapKbps = copyOptionalIntV3(req.BandwidthCapKbps) + start.Capabilities = req.Capabilities + start.ClientPlaybackContext = req.ClientPlaybackContext + applySelectedTracksToStartV3(&start, req.SelectedTracks) + } + requestedFallbackID := record.EffectiveMediaFileID + effectiveFallbackID := record.RequestedMediaFileID + if seekScopedRecovery { + // Edition fallback is useful for ordinary failure replans, but never for + // a seek operation: the caller asked to move within the currently mounted + // source, not to select another version when that source disappears. + requestedFallbackID = 0 + effectiveFallbackID = 0 + } + requestedFile, err := h.loadFileByPreferredID(r.Context(), record.RequestedMediaFileID, requestedFallbackID) + requestedEditionResolved := err == nil && requestedFile != nil && requestedFile.ID == record.RequestedMediaFileID + if err != nil || requestedFile == nil { + if !seekScopedRecovery { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "source_unavailable", message: "The requested media source is unavailable."} + } + // The requested edition is identity-only once another effective edition + // is mounted. Seeking must depend on that effective file remaining + // available, not on an inactive original edition still resolving. + requestedFile = &models.MediaFile{ID: record.RequestedMediaFileID} + } + plannerRequestedFile := requestedFile + if requestedFile.ID != record.RequestedMediaFileID { + // The live loader may fall back to the current effective file when the + // original edition is gone. Keep that file for metadata/remapping while + // preserving the durable requested-edition identity in every new plan. + plannerRequestedFile = &models.MediaFile{ID: record.RequestedMediaFileID} + } + currentEffectiveFile, err := h.loadFileByPreferredID(r.Context(), record.EffectiveMediaFileID, effectiveFallbackID) + if err != nil || currentEffectiveFile == nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "source_unavailable", message: "The effective media source is unavailable."} + } + effectiveFile := currentEffectiveFile + if intentChange { + // Prefer returning to the requested edition, but a quality/output/track + // change must not abandon a healthy active alternate merely because the + // inactive original has gone missing since playback started. + if requestedEditionResolved && preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub) == nil { + effectiveFile = requestedFile + } + // Track identities only need remapping when the effective edition + // actually changes. Remapping within the same file would degrade an + // exact selection to a best-match lookup — e.g. moving a listener + // from an eng/ac3 commentary track to the identically-shaped main + // track on a quality change. + if currentEffectiveFile.ID != effectiveFile.ID { + if err := remapAudioSelectionV3(currentEffectiveFile, effectiveFile, &start); err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()} + } + if start.SubtitleTrackIndex != nil || start.SubtitleTrackID != "" { + if err := h.remapSubtitleSelectionV3(r.Context(), currentEffectiveFile, effectiveFile, &start); err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()} + } + } + } + } + start.FileID = effectiveFile.ID + if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ + reason: "source_unavailable", + message: "The effective media source is unavailable.", + cause: err, + } + } + if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ + reason: "invalid_seek_position", + message: "The requested seek position is beyond the end of the selected media source.", + } + } + if _, err := start.NormalizeAndValidate(); err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()} + } + audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex) + if err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()} + } + attemptedKeys := []string(nil) + if !intentChange && !seekReanchor { + attemptedKeys = append(attemptedKeys, req.AttemptedPlanKeys...) + if !containsStringExactV3(attemptedKeys, req.PlanAttemptKey) { + attemptedKeys = append(attemptedKeys, req.PlanAttemptKey) + } + } + if !seekReanchor && (!intentChange || seekFailureRecovery) { + // Client attempt keys may include local mutations that the server cannot + // reproduce. Always exclude the durable server recipe as well so stale or + // malformed client history cannot immediately re-select the route that + // just failed and ping-pong the session. + currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil) + if !containsStringExactV3(attemptedKeys, currentKey) { + attemptedKeys = append(attemptedKeys, currentKey) + } + } + result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) { + if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil { + alternate = h.ensurePlaybackProbe(r.Context(), alternate) + remappedAudio := remapAudioIndexV3(effectiveFile, alternate, audioIndex) + if err := h.remapSubtitleSelectionV3(r.Context(), effectiveFile, alternate, &start); err == nil { + start.FileID = alternate.ID + if err := preflightPlaybackFile(r.Context(), alternate, h.MissingMarker, h.EventsHub); err == nil { + effectiveFile = alternate + audioIndex = remappedAudio + result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + } + } + } + } + if result.Terminal != nil { + return playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable), *record, nil, nil + } + session, err := h.sessionMgr.GetSession(record.SessionID) + if err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "session_expired", message: "The playback session has expired.", retryable: true} + } + replacementManager, ok := h.sessionMgr.(replacementStateManagerV3) + if !ok { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "internal_error", message: "The live session manager does not support atomic replacement."} + } + if checker, ok := h.sessionMgr.(replacementAdmissionCheckerV3); ok { + if err := checker.CheckReplacementAllowed(r.Context(), session.ID, result.PlayMethod, result.TranscodeAudio); err != nil { + mapped := sessionStartErrorV3(err) + return playback.DecisionResponseV3{}, *record, nil, mapped + } + _, reservationHeld = h.sessionMgr.(replacementReservationCancellerV3) + } + if checker, ok := h.sessionMgr.(transcodePermissionChecker); ok && (result.PlayMethod == playback.PlayTranscode || result.TranscodeAudio) { + if err := checker.CheckTranscodingAllowed(r.Context(), session.UserID, result.PlayMethod == playback.PlayTranscode); err != nil { + mapped := sessionStartErrorV3(err) + return playback.DecisionResponseV3{}, *record, nil, mapped + } + } + result.Plan.SessionID = session.ID + transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result) + if transportErr != nil { + return playback.DecisionResponseV3{}, *record, nil, transportErr + } + result.Plan.Stream.URL = transport.url + if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil { + transport.rollback() + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err} + } + if seekReanchor { + if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil { + transport.rollback() + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ + reason: "seek_reanchor_route_changed", + message: err.Error(), + } + } + } + response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureRouteDiagnostics, playback.FeatureDeviceQuirksV3, playback.FeatureSeekReanchorV3}, Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan} + updated := *record + updated.CurrentPlanID = result.Plan.PlanID + updated.CurrentPlan = *result.Plan + updated.NormalizedRequest = start + updated.EffectiveMediaFileID = effectiveFile.ID + updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL) + originalRollback := transport.rollback + replacement := playback.SessionReplacement{ + EffectiveMediaFileID: effectiveFile.ID, + StreamState: h.v3SessionStreamState(r.Context(), session, effectiveFile, result, transport), + } + if seekScopedRecovery { + replacement.PositionSeconds = &req.PositionSeconds + replacement.PreservePaused = true + } + transport.applySession = func() (func() error, error) { + rollback, err := replacementManager.ApplyReplacement(session.ID, replacement) + if err != nil { + return nil, err + } + return func() error { + return replacementManager.RollbackReplacement(session.ID, rollback) + }, nil + } + transport.afterDurableCommit = func() { + cancelReservation() + h.syncSessionsNow(r.Context(), "v3_replan") + event := playback.RouteEventPlanSelectedV3 + clientModel := req.ClientPlaybackContext.Device.Model + if seekReanchor { + event = playback.RouteEventRuntimeCorrectionSucceededV3 + clientModel = start.ClientPlaybackContext.Device.Model + } + h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: playback.RouteEventV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, PlanID: result.Plan.PlanID, PlanAttemptID: req.PlanAttemptID, PlanAttemptKey: playback.PlanAttemptKeyV3(*result.Plan, start.OutputRouteGeneration, nil), Event: event, FallbackReason: req.Failure.Classification, AppliedQuirkIDs: appliedQuirkIDsV3(result.Plan), QuirkRegistryRevision: appliedQuirkRevisionV3(result.Plan), OutputRouteGeneration: start.OutputRouteGeneration}, UserID: session.UserID, ProfileID: session.ProfileID, ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientModel: clientModel}) + } + transport.rollback = func() { + originalRollback() + cancelReservation() + } + reservationHandedOff = true + return response, updated, &transport, nil +} + +func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error { + if record == nil { + return errors.New("the current playback attempt is unavailable") + } + wantedQuality, _ := playback.NormalizeQualityV3(record.NormalizedRequest.QualityPreference) + requestedQuality, _ := playback.NormalizeQualityV3(req.QualityPreference) + if requestedQuality != wantedQuality { + return errors.New("seek recovery cannot change playback quality") + } + if req.OutputRouteGeneration != record.NormalizedRequest.OutputRouteGeneration { + return errors.New("seek recovery cannot change the output route") + } + if !sameSelectedTracksV3(req.SelectedTracks, record.CurrentPlan.SelectedTracks) { + return errors.New("seek recovery cannot change selected tracks") + } + return nil +} + +func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error { + if record == nil || candidate == nil { + return errors.New("seek reanchor produced no playback route") + } + current := record.CurrentPlan + if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID { + return errors.New("seek reanchor changed the playback plan identity") + } + if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID { + return errors.New("seek reanchor changed the selected media version") + } + if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) { + return errors.New("seek reanchor changed selected tracks") + } + if candidate.Engine != current.Engine || + candidate.Stream.MIMEType != current.Stream.MIMEType || + candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh || + !sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) || + candidate.Claims != current.Claims || + candidate.Subtitle.Mode != current.Subtitle.Mode || + candidate.Subtitle.TrackID != current.Subtitle.TrackID || + !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) || + candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy || + !sameTransformationsV3(candidate.Transformations, current.Transformations) || + !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) || + !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) { + return errors.New("seek reanchor changed the playback route semantics") + } + generation := record.NormalizedRequest.OutputRouteGeneration + currentKey := playback.PlanAttemptKeyV3(current, generation, nil) + candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil) + if candidateKey != currentKey { + return errors.New("seek reanchor changed the playback route recipe") + } + return nil +} + +func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + // Signed URLs and timing origins are allowed to rotate when a transport is + // reopened; the player-facing artifact representation is not. + return left.MIMEType == right.MIMEType && left.Format == right.Format +} + +func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool { + return left.VideoCodec == right.VideoCodec && + left.AudioCodec == right.AudioCodec && + optionalIntEqualV3(left.Width, right.Width) && + optionalIntEqualV3(left.Height, right.Height) && + optionalFloatEqualV3(left.FrameRate, right.FrameRate) && + optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) && + left.DynamicRange == right.DynamicRange && + optionalIntEqualV3(left.AudioChannels, right.AudioChannels) && + left.AudioLayout == right.AudioLayout +} + +func sameTransformationsV3(left, right []playback.TransformationV3) bool { + if len(left) != len(right) { + return false + } + matched := make([]bool, len(right)) + for _, candidate := range left { + found := false + for index, current := range right { + if !matched[index] && candidate.Name == current.Name && candidate.Executor == current.Executor && + candidate.RecipeVersion == current.RecipeVersion && sameStringMultisetV3(candidate.ValidatedClaims, current.ValidatedClaims) { + matched[index] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func sameAppliedQuirksV3(left, right []playback.AppliedQuirkV3) bool { + if len(left) != len(right) { + return false + } + matched := make([]bool, len(right)) + for _, candidate := range left { + found := false + for index, current := range right { + if !matched[index] && candidate == current { + matched[index] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func sameStringMultisetV3(left, right []string) bool { + if len(left) != len(right) { + return false + } + counts := make(map[string]int, len(left)) + for _, value := range left { + counts[value]++ + } + for _, value := range right { + counts[value]-- + if counts[value] < 0 { + return false + } + } + return true +} + +func applySelectedTracksToStartV3(start *playback.StartRequestV3, selected playback.SelectedTracksV3) { + if start == nil { + return + } + if selected.Audio != nil { + start.AudioTrackID = selected.Audio.ID + start.AudioTrackIndex = copyOptionalIntV3(selected.Audio.Index) + } + if selected.Subtitle != nil { + start.SubtitleTrackID = selected.Subtitle.ID + start.SubtitleTrackIndex = copyOptionalIntV3(selected.Subtitle.Index) + } else { + start.SubtitleTrackID = "" + start.SubtitleTrackIndex = nil + } +} + +func sameSelectedTracksV3(left, right playback.SelectedTracksV3) bool { + return sameTrackIdentityV3(left.Audio, right.Audio) && sameTrackIdentityV3(left.Subtitle, right.Subtitle) +} + +func sameTrackIdentityV3(left, right *playback.TrackIdentityV3) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.ID == right.ID && optionalIntEqualV3(left.Index, right.Index) +} + +func copyOptionalIntV3(value *int) *int { + if value == nil { + return nil + } + copy := *value + return © +} + +func shouldTryAlternateFileV3(qualityPreference string) bool { + return !strings.EqualFold(strings.TrimSpace(qualityPreference), "original") +} + +func replanAllowsAlternateFileV3(operation playback.ReplanOperationV3, qualityPreference string) bool { + return operation == playback.ReplanOperationFailureRecoveryV3 && shouldTryAlternateFileV3(qualityPreference) +} + +func (h *PlaybackHandler) lockReplanV3(sessionID string) func() { + h.v3ReplanMu.Lock() + if h.v3ReplanLocks == nil { + h.v3ReplanLocks = make(map[string]*v3ReplanLock) + } + entry := h.v3ReplanLocks[sessionID] + if entry == nil { + entry = &v3ReplanLock{} + h.v3ReplanLocks[sessionID] = entry + } + entry.refs++ + h.v3ReplanMu.Unlock() + entry.mu.Lock() + return func() { + entry.mu.Unlock() + h.v3ReplanMu.Lock() + entry.refs-- + if entry.refs == 0 { + delete(h.v3ReplanLocks, sessionID) + } + h.v3ReplanMu.Unlock() + } +} + +// maxConcurrentReplansV3 bounds simultaneous replan executions. Each replan +// pins one pooled DB connection for its advisory session lock while issuing +// further store queries from the same pool; without a bound, a recovery storm +// (a transcode node dying with dozens of active sessions) turns every pool +// connection into a lock holder and the inner queries deadlock against them. +const maxConcurrentReplansV3 = 8 + +// sessionLockCapacityAdvisorV3 lets a plan store cap replan concurrency below +// the fixed default when its own connection budget is smaller; a pool sized at +// or below the default would otherwise let lock holders starve the inner +// store queries that must complete before any lock is released. +type sessionLockCapacityAdvisorV3 interface { + SessionLockCapacity() int +} + +// acquireReplanSlotV3 blocks until a replan slot frees or the request context +// is cancelled; excess replans queue here holding no DB resources at all. +func (h *PlaybackHandler) acquireReplanSlotV3(ctx context.Context) (func(), error) { + h.v3ReplanSlotsOnce.Do(func() { + capacity := maxConcurrentReplansV3 + if advisor, ok := h.PlanStoreV3.(sessionLockCapacityAdvisorV3); ok { + if advised := advisor.SessionLockCapacity(); advised > 0 && advised < capacity { + capacity = advised + } + } + h.v3ReplanSlots = make(chan struct{}, capacity) + }) + select { + case h.v3ReplanSlots <- struct{}{}: + var once sync.Once + return func() { once.Do(func() { <-h.v3ReplanSlots }) }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (h *PlaybackHandler) HandlePlaybackRouteEventV3(w http.ResponseWriter, r *http.Request) { + userID := apimw.GetUserID(r.Context()) + profileID := apimw.GetProfileID(r.Context()) + if userID == 0 || profileID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication and profile are required") + return + } + if !h.protocolV3Enabled(r.Context()) { + writeError(w, http.StatusConflict, "protocol_disabled", "Playback protocol v3 is disabled") + return + } + body, err := readBoundedV3Body(w, r, maxPlaybackV3EventBodyBytes) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid event body") + return + } + var event playback.RouteEventV3 + if err := json.Unmarshal(body, &event); err != nil || !validRouteEventV3(event) { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid route event") + return + } + // The rate limiter runs before the ownership lookup so the per-minute + // budget bounds the store reads as well as the writes. + if !h.allowRouteEventV3(userID, event.PlaybackAttemptID) { + writeError(w, http.StatusTooManyRequests, "event_rate_limited", "Playback route event rate exceeded") + return + } + var identity *playback.AttemptIdentityV3 + var identityErr error + if event.SessionID != "" { + identity, identityErr = h.PlanStoreV3.GetAttemptIdentity(r.Context(), event.SessionID) + } else { + identity, identityErr = h.PlanStoreV3.GetAttemptIdentityByPlaybackAttemptID(r.Context(), event.PlaybackAttemptID) + } + if identityErr != nil { + // A store outage is not an ownership violation; keep 403 for genuine + // mismatches so clients stop sending events for foreign sessions. + if !errors.Is(identityErr, playback.ErrSessionNotFound) { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize the route event") + return + } + writeError(w, http.StatusForbidden, "forbidden", "Route event does not belong to this profile") + return + } + if identity.UserID != userID || identity.ProfileID != profileID || + (event.SessionID != "" && identity.PlaybackAttemptID != event.PlaybackAttemptID) { + writeError(w, http.StatusForbidden, "forbidden", "Route event does not belong to this profile") + return + } + event.Diagnostics = sanitizeDiagnosticsV3(event.Diagnostics) + client := playbackClientInfoFromRequest(r) + h.enqueueRouteEventV3(playback.RouteEventRecordV3{RouteEventV3: event, UserID: userID, ProfileID: profileID, ClientName: client.Name, ClientVersion: client.Version, ClientModel: event.Diagnostics["device_model"]}) + w.WriteHeader(http.StatusAccepted) +} + +// StartV3Maintenance expires cached signed responses and old telemetry on the +// application lifecycle rather than on latency-sensitive playback requests. +func (h *PlaybackHandler) StartV3Maintenance(ctx context.Context) { + if h == nil || h.PlanStoreV3 == nil || ctx == nil { + return + } + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if _, err := h.PlanStoreV3.CleanupExpired(cleanupCtx, now); err != nil { + slog.Warn("playback v3 cleanup failed", "error", err) + } + cancel() + } + } + }() +} + +func (h *PlaybackHandler) allowRouteEventV3(userID int, attemptID string) bool { + attemptKey := fmt.Sprintf("attempt:%d:%s", userID, attemptID) + userKey := fmt.Sprintf("user:%d", userID) + now := time.Now() + h.v3EventRateMu.Lock() + defer h.v3EventRateMu.Unlock() + if h.v3EventRates == nil { + h.v3EventRates = make(map[string]v3EventRate) + } + attemptEntry := h.v3EventRates[attemptKey] + if attemptEntry.windowStart.IsZero() || now.Sub(attemptEntry.windowStart) >= time.Minute { + attemptEntry = v3EventRate{windowStart: now} + } + userEntry := h.v3EventRates[userKey] + if userEntry.windowStart.IsZero() || now.Sub(userEntry.windowStart) >= time.Minute { + userEntry = v3EventRate{windowStart: now} + } + if attemptEntry.count >= 120 || userEntry.count >= 600 { + return false + } + attemptEntry.count++ + userEntry.count++ + h.v3EventRates[attemptKey] = attemptEntry + h.v3EventRates[userKey] = userEntry + if len(h.v3EventRates) > 10_000 { + for candidate, value := range h.v3EventRates { + if now.Sub(value.windowStart) > 2*time.Minute { + delete(h.v3EventRates, candidate) + } + } + } + return true +} + +func (h *PlaybackHandler) enqueueRouteEventV3(event playback.RouteEventRecordV3) { + if h == nil || h.PlanStoreV3 == nil { + return + } + h.v3EventOnce.Do(func() { + h.v3EventQueue = make(chan playback.RouteEventRecordV3, 512) + go func() { + for value := range h.v3EventQueue { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := h.PlanStoreV3.RecordRouteEvent(ctx, value); err != nil { + slog.Warn("playback route event write failed", "error", err, "event", value.Event) + } + cancel() + } + }() + }) + select { + case h.v3EventQueue <- event: + default: + slog.Warn("playback route event dropped", "event", event.Event, "playback_attempt_id", event.PlaybackAttemptID) + } +} + +func (h *PlaybackHandler) plannerSettingsV3(ctx context.Context) playback.PlannerSettingsV3 { + settings := playback.PlannerSettingsV3{TranscodeEnabled: h.playbackConfig().TranscodeEnabled} + if h.SettingsRepo != nil { + value, _ := h.SettingsRepo.Get(ctx, "allow_4k_transcode") + settings.Allow4KTranscode = strings.EqualFold(value, "true") + } + return settings +} + +func resolveV3AudioIndex(file *models.MediaFile, trackID string, fallback *int) (int, error) { + index := 0 + if trackID != "" { + fileID, kind, ordinal, ok := playback.ParseTrackIDV3(trackID) + if !ok || kind != "audio" || file == nil || fileID != file.ID { + return 0, errors.New("selected audio track identity is invalid") + } + index = ordinal + } else if fallback != nil { + index = *fallback + } + if file == nil || len(file.AudioTracks) == 0 { + if index == 0 { + return 0, nil + } + return 0, errors.New("selected audio track is unavailable") + } + if index < 0 || index >= len(file.AudioTracks) { + return 0, errors.New("selected audio track is unavailable") + } + return index, nil +} + +func remapAudioIndexV3(source, target *models.MediaFile, index int) int { + if source == nil || target == nil || index < 0 || index >= len(source.AudioTracks) { + return normalizeAudioTrackIndex(target, index) + } + wanted := source.AudioTracks[index] + for i, candidate := range target.AudioTracks { + if strings.EqualFold(candidate.Codec, wanted.Codec) && strings.EqualFold(candidate.Language, wanted.Language) && candidate.Channels == wanted.Channels { + return i + } + } + return normalizeAudioTrackIndex(target, index) +} + +// remapAudioSelectionV3 rebinds the request's audio selection when the +// effective media file changes. ID-only selections are equally file-bound: +// the stale ID would be rejected against the new file's track list +// downstream, so derive the source index from it and remap like any other. +func remapAudioSelectionV3(source, target *models.MediaFile, request *playback.StartRequestV3) error { + if request == nil || source == nil || target == nil || source.ID == target.ID { + return nil + } + if request.AudioTrackIndex == nil { + if request.AudioTrackID == "" { + return nil + } + fileID, kind, ordinal, ok := playback.ParseTrackIDV3(request.AudioTrackID) + if !ok || kind != "audio" || fileID != source.ID { + return errors.New("The selected audio track identity is invalid for the source file.") + } + request.AudioTrackIndex = &ordinal + } + remapped := remapAudioIndexV3(source, target, *request.AudioTrackIndex) + request.AudioTrackIndex = &remapped + request.AudioTrackID = playback.TrackIDV3(target.ID, "audio", remapped) + return nil +} + +func (h *PlaybackHandler) remapSubtitleSelectionV3(ctx context.Context, source, target *models.MediaFile, request *playback.StartRequestV3) error { + if request == nil || source == nil || target == nil || source.ID == target.ID { + return nil + } + if request.SubtitleTrackIndex == nil { + // ID-only selections are equally file-bound: the stale ID would be + // parsed against the alternate file's track list downstream, so + // derive the source index from it and remap like any other. + if request.SubtitleTrackID == "" { + return nil + } + fileID, kind, ordinal, ok := playback.ParseTrackIDV3(request.SubtitleTrackID) + if !ok || kind != "subtitle" || fileID != source.ID { + return errors.New("The selected subtitle track identity is invalid for the source file.") + } + request.SubtitleTrackIndex = &ordinal + } + index := *request.SubtitleTrackIndex + if index < 0 { + return errors.New("The selected subtitle track index is invalid.") + } + targetIndex := -1 + switch { + case index < len(source.ExternalSubtitles): + wanted := source.ExternalSubtitles[index] + for candidateIndex, candidate := range target.ExternalSubtitles { + if strings.EqualFold(candidate.Language, wanted.Language) && strings.EqualFold(candidate.Format, wanted.Format) && candidate.Forced == wanted.Forced { + targetIndex = candidateIndex + break + } + } + case index < len(source.ExternalSubtitles)+len(source.SubtitleTracks): + wanted := source.SubtitleTracks[index-len(source.ExternalSubtitles)] + for candidateIndex, candidate := range target.SubtitleTracks { + if strings.EqualFold(candidate.Language, wanted.Language) && strings.EqualFold(candidate.Codec, wanted.Codec) && candidate.Forced == wanted.Forced { + targetIndex = len(target.ExternalSubtitles) + candidateIndex + break + } + } + default: + if h.SubtitleRepo != nil { + sourceDownloaded, sourceErr := h.SubtitleRepo.ListDownloadedSubtitles(ctx, source.ID) + targetDownloaded, targetErr := h.SubtitleRepo.ListDownloadedSubtitles(ctx, target.ID) + downloadedIndex := index - len(source.ExternalSubtitles) - len(source.SubtitleTracks) + if sourceErr == nil && targetErr == nil && downloadedIndex >= 0 && downloadedIndex < len(sourceDownloaded) { + wanted := sourceDownloaded[downloadedIndex] + for candidateIndex, candidate := range targetDownloaded { + if strings.EqualFold(candidate.Language, wanted.Language) && strings.EqualFold(string(candidate.Format), string(wanted.Format)) && strings.EqualFold(candidate.ReleaseName, wanted.ReleaseName) { + targetIndex = len(target.ExternalSubtitles) + len(target.SubtitleTracks) + candidateIndex + break + } + } + } + } + } + if targetIndex < 0 { + return errors.New("The selected subtitle track is unavailable in the effective file version.") + } + request.SubtitleTrackIndex = &targetIndex + request.SubtitleTrackID = playback.TrackIDV3(target.ID, "subtitle", targetIndex) + return nil +} + +func sessionStartErrorV3(err error) *transportErrorV3 { + switch { + case errors.Is(err, playback.ErrTooManyStreams), errors.Is(err, playback.ErrTooManyTranscodes): + return &transportErrorV3{reason: "capacity_unavailable", message: "Playback capacity is currently unavailable.", retryable: true} + case errors.Is(err, playback.ErrTranscodingDisabled), errors.Is(err, playback.ErrAudioTranscodingDisabled): + return &transportErrorV3{reason: "transcoding_disabled", message: "The selected server adaptation is disabled."} + case errors.Is(err, playback.ErrPlaybackNotAllowed): + return &transportErrorV3{reason: "policy_denied", message: "Playback is denied by server policy."} + default: + return &transportErrorV3{reason: "internal_error", message: "Failed to start the playback session.", cause: err} + } +} + +func decisionResponseFromAttemptV3(record *playback.AttemptRecordV3) playback.DecisionResponseV3 { + if record == nil { + return playback.DecisionResponseV3{} + } + plan := record.CurrentPlan + if plan.AppliedQuirks == nil { + plan.AppliedQuirks = []playback.AppliedQuirkV3{} + } + if plan.RuntimeCorrections == nil { + plan.RuntimeCorrections = []string{} + } + return playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureRouteDiagnostics, playback.FeatureDeviceQuirksV3, playback.FeatureSeekReanchorV3}, Outcome: playback.OutcomePlayableV3, SessionID: record.SessionID, PlaybackPlan: &plan} +} + +func completedReplanResponseMatchesAttemptV3(raw json.RawMessage, record *playback.AttemptRecordV3) bool { + if record == nil { + return false + } + var response playback.DecisionResponseV3 + if len(raw) == 0 || json.Unmarshal(raw, &response) != nil { + return false + } + if response.PlaybackPlan == nil { + // Terminal responses deliberately leave the attempt plan untouched. Their + // freshness is carried by CurrentReplanRequestID (and its DB trigger). + return response.Terminal != nil + } + if response.SessionID != record.SessionID || response.PlaybackPlan.SessionID != record.SessionID { + return false + } + candidate, candidateErr := json.Marshal(response.PlaybackPlan) + current, currentErr := json.Marshal(record.CurrentPlan) + return candidateErr == nil && currentErr == nil && bytes.Equal(candidate, current) +} + +func appliedQuirkIDsV3(plan *playback.PlanV3) []string { + if plan == nil { + return nil + } + result := make([]string, 0, len(plan.AppliedQuirks)) + for _, quirk := range plan.AppliedQuirks { + result = append(result, quirk.ID) + } + return result +} + +func appliedQuirkRevisionV3(plan *playback.PlanV3) string { + if plan == nil || len(plan.AppliedQuirks) == 0 { + return "" + } + return plan.AppliedQuirks[0].RegistryRevision +} + +func writeV3FileError(w http.ResponseWriter, err error) { + if errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Media file not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file") +} +func readBoundedV3Body(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) { + return ioReadAllV3(http.MaxBytesReader(w, r.Body, limit)) +} +func ioReadAllV3(reader interface{ Read([]byte) (int, error) }) ([]byte, error) { + var buffer bytes.Buffer + _, err := buffer.ReadFrom(reader) + return buffer.Bytes(), err +} +func chiURLParamV3(r *http.Request, key string) string { return chi.URLParam(r, key) } +func floatOrZeroHandlerV3(v *float64) float64 { + if v == nil { + return 0 + } + return *v +} +func firstNonEmptyHandlerV3(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} +func subtitleMIMEV3(format string) string { + switch strings.ToLower(format) { + case "ass", "ssa": + return "text/x-ssa" + case "srt", "subrip": + return "application/x-subrip" + case "pgs", "hdmv_pgs_subtitle": + return "application/octet-stream" + default: + return "text/vtt" + } +} + +func forceSubtitleExtensionV3(rawURL, extension string) string { + pathPart, query, hasQuery := strings.Cut(rawURL, "?") + if slash := strings.LastIndex(pathPart, "/"); slash >= 0 { + if dot := strings.LastIndex(pathPart[slash+1:], "."); dot >= 0 { + pathPart = pathPart[:slash+1+dot] + extension + } else { + pathPart += extension + } + } + if hasQuery { + return pathPart + "?" + query + } + return pathPart +} + +func remuxDVModeForPlanV3(plan *playback.PlanV3) playback.RemuxDVMode { + if plan == nil { + return "" + } + for _, transformation := range plan.Transformations { + if transformation.Name == "server_dv7_to_hdr10" { + return playback.RemuxDVStripToHDR10V3 + } + } + if plan.Source.DVProfile == 0 { + return "" + } + if plan.Source.DVProfile == 7 { + // Without the strip transformation a P7 remux would drop the + // enhancement layer and leave dangling RPUs. A P7 plan claiming Dolby + // Vision is a client-side transform of the original bytes, so any + // remux attempt against this session must still be rejected. + return playback.RemuxDVRejectP7V3 + } + if plan.Claims.Video.DolbyVision { + return playback.RemuxDVPreserveV3 + } + return "" +} + +func videoBitstreamFilterForPlanV3(plan *playback.PlanV3) string { + if plan == nil { + return "" + } + for _, transformation := range plan.Transformations { + if transformation.Executor == "server" && transformation.Name == "server_dv7_to_hdr10" && transformation.RecipeVersion == "1" { + return playback.DV7ToHDR10BitstreamFilter + } + } + return "" +} + +func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDuration int, durationSeconds float64) (float64, int) { + if plan == nil { + return 0, 0 + } + requested := plan.Timeline.SourceStartSeconds + seek := alignedSeekSeconds(requested, segmentDuration, videoCodec) + startSegment := computeStartSegment(seek, segmentDuration) + plan.Timeline.SourceStartSeconds = requested + if strings.EqualFold(videoCodec, "copy") { + plan.Timeline.PlayerStartSeconds = 0 + plan.Timeline.StreamOriginSeconds = seek + plan.Timeline.TimelineOffsetSeconds = seek + windowStart := seek + plan.Timeline.SeekWindowStartSeconds = &windowStart + if durationSeconds > 0 { + windowEnd := durationSeconds + plan.Timeline.SeekWindowEndSeconds = &windowEnd + } else { + plan.Timeline.SeekWindowEndSeconds = nil + } + plan.Timeline.CanSeekAnywhere = false + plan.Timeline.SeekRestoration = "source_position" + } else { + plan.Timeline.PlayerStartSeconds = requested + plan.Timeline.StreamOriginSeconds = 0 + plan.Timeline.TimelineOffsetSeconds = 0 + plan.Timeline.SeekWindowStartSeconds = nil + plan.Timeline.SeekWindowEndSeconds = nil + plan.Timeline.CanSeekAnywhere = durationSeconds > 0 + plan.Timeline.SeekRestoration = "player_position" + } + return seek, startSegment +} + +var diagnosticKeysV3 = map[string]struct{}{ + "decoder_name": {}, "decoder_init_ms": {}, "first_frame_ms": {}, + "device_model": {}, "requested_quality": {}, "effective_quality": {}, + "pcm_recovery": {}, "retry_outcome": {}, "replan_request_id": {}, + "video_mime": {}, "video_codecs": {}, "video_width": {}, "video_height": {}, + "color_transfer": {}, "color_range": {}, + "error_code": {}, "error_code_name": {}, "error_cause": {}, + "transformation_name": {}, "transformation_version": {}, "transformation_stage": {}, + "input_dv_profile": {}, "output_dv_profile": {}, "rpu_converted_count": {}, + "rpu_failed_count": {}, "el_nal_dropped_count": {}, "sample_count": {}, + "transform_buffer_peak_bytes": {}, "requested_media_file_id": {}, "effective_media_file_id": {}, + "audio_output_mode": {}, "audio_mime": {}, "audio_channels": {}, "audio_decoder_name": {}, + "correction_id": {}, "correction_stage": {}, + "network_transport": {}, "network_metered": {}, "network_validated": {}, + "bandwidth_estimate_kbps": {}, "link_downstream_kbps": {}, + "target_source_position_seconds": {}, "reason": {}, +} + +func validRouteEventV3(event playback.RouteEventV3) bool { + if event.ProtocolVersion != playback.ProtocolV3 || len(event.PlaybackAttemptID) < 8 || len(event.PlaybackAttemptID) > 128 || event.OutputRouteGeneration < 0 || len(event.SessionID) > 128 || len(event.PlanID) > 128 || len(event.PlanAttemptID) > 128 || len(event.PlanAttemptKey) > 128 || len(event.FailureClassification) > 64 || len(event.FallbackReason) > 64 || len(event.AppliedQuirkIDs) > 16 || len(event.QuirkRegistryRevision) > 128 || len(event.Diagnostics) > 32 { + return false + } + for _, id := range event.AppliedQuirkIDs { + if len(id) == 0 || len(id) > 128 { + return false + } + } + return playback.ValidRouteEventNameV3(event.Event) +} +func sanitizeDiagnosticsV3(values map[string]string) map[string]string { + // Iterate the approved keys, not the client map: map iteration order is + // random, so a count-limited walk over client keys would keep an + // arbitrary subset and drop different diagnostics on identical retries. + result := make(map[string]string) + for key := range diagnosticKeysV3 { + value, ok := values[key] + if !ok { + continue + } + value = strings.TrimSpace(value) + if len(value) > 256 { + value = value[:256] + } + result[key] = value + } + return result +} + +func containsStringFoldV3(values []string, wanted string) bool { + for _, value := range values { + if strings.EqualFold(value, wanted) { + return true + } + } + return false +} + +// containsStringExactV3 compares attempt keys byte-for-byte: they are +// case-sensitive FNV hex digests, so case-folding would treat distinct keys +// as equal. +func containsStringExactV3(values []string, wanted string) bool { + wanted = strings.TrimSpace(wanted) + for _, value := range values { + if strings.TrimSpace(value) == wanted { + return true + } + } + return false +} + +func optionalIntEqualV3(left, right *int) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +func optionalFloatEqualV3(left, right *float64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} diff --git a/internal/api/handlers/playback_v3_shadow.go b/internal/api/handlers/playback_v3_shadow.go new file mode 100644 index 00000000..365bf7b1 --- /dev/null +++ b/internal/api/handlers/playback_v3_shadow.go @@ -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 +} diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go new file mode 100644 index 00000000..25ef7c2a --- /dev/null +++ b/internal/api/handlers/playback_v3_test.go @@ -0,0 +1,1338 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodepool" + "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/transcodenode" +) + +type mutablePlaybackSettingsV3 struct { + mu sync.Mutex + values map[string]string +} + +type failingCompletePlanStoreV3 struct { + playback.PlanStoreV3 +} + +type staticNodePlannerV3 struct { + plan nodepool.Plan +} + +func (p staticNodePlannerV3) PlanSession(string, string, bool, int) nodepool.Plan { + return p.plan +} + +func (f *failingCompletePlanStoreV3) CompleteReplan(context.Context, string, string, string, json.RawMessage, playback.AttemptRecordV3) error { + return fmt.Errorf("injected complete replan failure") +} + +func TestShouldTryAlternateFileV3PinsOriginalQuality(t *testing.T) { + if shouldTryAlternateFileV3("original") || shouldTryAlternateFileV3(" ORIGINAL ") { + t.Fatal("original quality must pin the requested media file") + } + for _, quality := range []string{"auto", "2160p", "1080p", "480p"} { + if !shouldTryAlternateFileV3(quality) { + t.Fatalf("quality %q should permit alternate selection", quality) + } + } +} + +func TestReplanAllowsAlternateFileV3PinsSeekOperations(t *testing.T) { + tests := []struct { + name string + operation playback.ReplanOperationV3 + quality string + want bool + }{ + {name: "ordinary failure may use another version", operation: playback.ReplanOperationFailureRecoveryV3, quality: "auto", want: true}, + {name: "original quality remains pinned", operation: playback.ReplanOperationFailureRecoveryV3, quality: "original", want: false}, + {name: "exact seek reanchor pins current version", operation: playback.ReplanOperationSeekReanchorV3, quality: "auto", want: false}, + {name: "failed seek recovery pins current version", operation: playback.ReplanOperationSeekFailureRecoveryV3, quality: "auto", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := replanAllowsAlternateFileV3(test.operation, test.quality); got != test.want { + t.Fatalf("replanAllowsAlternateFileV3(%q, %q) = %v, want %v", test.operation, test.quality, got, test.want) + } + }) + } +} + +func (s *mutablePlaybackSettingsV3) Get(_ context.Context, key string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.values[key], nil +} + +func (s *mutablePlaybackSettingsV3) set(key, value string) { + s.mu.Lock() + defer s.mu.Unlock() + s.values[key] = value +} + +func TestHandleStartPlaybackV3DisabledDoesNotAllocateLegacySession(t *testing.T) { + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}} + + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, v3HandlerStartRequest()))) + req = req.WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.ProtocolVersion != playback.ProtocolV3 || len(response.ServerFeatures) != 0 || response.SessionID != "" { + t.Fatalf("response = %#v", response) + } + if got := len(manager.AllSessions()); got != 0 { + t.Fatalf("sessions = %d, want 0", got) + } +} + +func TestHandlePlaybackCapabilityV3ReadsFlagPerRequest(t *testing.T) { + settings := &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.SettingsRepo = settings + + request := func() playback.CapabilityResponseV3 { + req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/capability", nil).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandlePlaybackCapabilityV3(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.CapabilityResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response + } + + if response := request(); response.Enabled || response.Reason != "disabled" { + t.Fatalf("disabled response = %#v", response) + } + settings.set("playback.protocol_v3_enabled", "true") + // The flag stays DB-backed (no restart needed for rollback) behind a + // short TTL cache; expiring the cache stands in for the TTL elapsing. + handler.v3FlagMu.Lock() + handler.v3Flags = nil + handler.v3FlagMu.Unlock() + if response := request(); !response.Enabled || len(response.Deliveries) != 4 || !playback.HasFeatureV3(response.Features, playback.FeatureSeekReanchorV3) { + t.Fatalf("enabled response = %#v", response) + } +} + +func TestHandleStartPlaybackV3ReturnsExecutableDirectPlan(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, v3HandlerStartRequest()))) + req = req.WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Outcome != playback.OutcomePlayableV3 || response.PlaybackPlan == nil { + t.Fatalf("response = %#v", response) + } + if response.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 || response.PlaybackPlan.Engine != playback.EngineMedia3DirectV3 || response.PlaybackPlan.Stream.URL == "" { + t.Fatalf("plan = %#v", response.PlaybackPlan) + } + if response.PlaybackPlan.RequestedMediaFileID != file.ID || response.PlaybackPlan.EffectiveMediaFileID != file.ID || response.PlaybackPlan.Source.MediaFileID != file.ID { + t.Fatalf("source identity = %#v", response.PlaybackPlan) + } + if got := len(manager.AllSessions()); got != 1 { + t.Fatalf("sessions = %d, want 1", got) + } +} + +func TestHandleStartPlaybackV3DuplicateAttemptReturnsOriginalSession(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + body := marshalV3StartRequest(t, v3HandlerStartRequest()) + + start := func() playback.DecisionResponseV3 { + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response + } + first := start() + second := start() + if first.SessionID == "" || second.SessionID != first.SessionID { + t.Fatalf("first session %q, second %q", first.SessionID, second.SessionID) + } + if got := len(manager.AllSessions()); got != 1 { + t.Fatalf("sessions = %d, want 1", got) + } +} + +func TestHandleStartPlaybackV3RejectsProfileMismatch(t *testing.T) { + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + request := v3HandlerStartRequest() + request.ProfileID = "profile-other" + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != http.StatusBadRequest || len(manager.AllSessions()) != 0 { + t.Fatalf("status = %d, sessions = %d, body = %s", rr.Code, len(manager.AllSessions()), rr.Body.String()) + } +} + +func TestHandleReplanPlaybackV3UpdatesSelectedAudioAndReplaysIdempotently(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"}) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.PlaybackPlan == nil { + t.Fatal("start returned no plan") + } + audioIndex := 1 + bandwidthEstimate := 3_500 + bandwidthCap := 4_000 + failedKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + replan := playback.ReplanRequestV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "replan-0001", FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "plan-attempt-0001", PlanAttemptKey: failedKey, AttemptedPlanKeys: []string{failedKey}, AttemptCount: 1, QualityPreference: "original", PositionSeconds: 12, OutputRouteGeneration: startRequest.OutputRouteGeneration, Metered: true, BandwidthEstimateKbps: &bandwidthEstimate, BandwidthCapKbps: &bandwidthCap, SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "audio", audioIndex), Index: &audioIndex}}, Failure: playback.FailureV3{Classification: "audio_renderer_error"}, Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext} + replanBody, err := json.Marshal(replan) + if err != nil { + t.Fatal(err) + } + + call := func() playback.DecisionResponseV3 { + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(replanBody))).WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", started.SessionID) + rr := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response + } + first := call() + second := call() + if first.PlaybackPlan == nil || second.PlaybackPlan == nil || first.PlaybackPlan.PlanID != second.PlaybackPlan.PlanID { + t.Fatalf("first=%#v second=%#v", first, second) + } + session, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + if session.AudioTrackIndex != audioIndex { + t.Fatalf("audio index = %d, want %d", session.AudioTrackIndex, audioIndex) + } + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if !record.NormalizedRequest.Metered || record.NormalizedRequest.BandwidthEstimateKbps == nil || *record.NormalizedRequest.BandwidthEstimateKbps != bandwidthEstimate || + record.NormalizedRequest.BandwidthCapKbps == nil || *record.NormalizedRequest.BandwidthCapKbps != bandwidthCap { + t.Fatalf("stored replan network evidence = %#v", record.NormalizedRequest) + } + replan.PositionSeconds++ + conflictBody, err := json.Marshal(replan) + if err != nil { + t.Fatal(err) + } + conflictReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(conflictBody))).WithContext(newAuthorizedPlaybackContext()) + conflictReq = withPlaybackRouteParam(conflictReq, "session_id", started.SessionID) + conflictRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(conflictRR, conflictReq) + if conflictRR.Code != http.StatusConflict || !strings.Contains(conflictRR.Body.String(), "idempotency_key_reused") { + t.Fatalf("conflict status = %d, body = %s", conflictRR.Code, conflictRR.Body.String()) + } +} + +func TestHandleReplanPlaybackV3RollsBackLiveSessionWhenPersistenceFails(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"}) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil || started.PlaybackPlan == nil { + t.Fatalf("start response: err=%v response=%#v", err, started) + } + beforeSession, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + beforeRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + underlyingStore := handler.PlanStoreV3 + handler.PlanStoreV3 = &failingCompletePlanStoreV3{PlanStoreV3: underlyingStore} + + audioIndex := 1 + failedKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + replan := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "replan-rollback-0001", + FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "plan-attempt-rollback-0001", + PlanAttemptKey: failedKey, + AttemptedPlanKeys: []string{failedKey}, + AttemptCount: 1, + QualityPreference: "original", + PositionSeconds: 12, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ + ID: playback.TrackIDV3(file.ID, "audio", audioIndex), Index: &audioIndex, + }}, + Failure: playback.FailureV3{Classification: "audio_renderer_error"}, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + } + body, err := json.Marshal(replan) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", started.SessionID) + rr := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String()) + } + + afterSession, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + if afterSession.MediaFileID != beforeSession.MediaFileID || afterSession.AudioTrackIndex != beforeSession.AudioTrackIndex || + afterSession.PlayMethod != beforeSession.PlayMethod || afterSession.TranscodeNodeURL != beforeSession.TranscodeNodeURL { + t.Fatalf("live session was not rolled back: before=%#v after=%#v", beforeSession, afterSession) + } + afterRecord, err := underlyingStore.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if afterRecord.CurrentPlanID != beforeRecord.CurrentPlanID || afterRecord.CurrentReplanRequestID != beforeRecord.CurrentReplanRequestID { + t.Fatalf("durable attempt changed after failed commit: before=%#v after=%#v", beforeRecord, afterRecord) + } +} + +func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass", Language: "eng"}} + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "false"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + subtitleIndex := 0 + startRequest.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", subtitleIndex) + startRequest.SubtitleTrackIndex = &subtitleIndex + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.PlaybackPlan == nil { + t.Fatal("start returned no plan") + } + if started.PlaybackPlan.Subtitle.Artifact == nil || started.PlaybackPlan.Subtitle.Artifact.Format != "ass" { + t.Fatalf("start returned no ASS artifact: %#v", started.PlaybackPlan.Subtitle) + } + if err := manager.UpdateProgress(started.SessionID, 12, true); err != nil { + t.Fatal(err) + } + currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + reanchor := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "plan-attempt-seek-0001", PlanAttemptKey: currentKey, + // Clients may include the current key defensively. A seek reanchor must + // ignore it because the recipe did not fail. + AttemptedPlanKeys: []string{currentKey}, AttemptCount: 1, + QualityPreference: "original", PositionSeconds: 321, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: started.PlaybackPlan.SelectedTracks, + Failure: playback.FailureV3{}, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext, + } + body, err := json.Marshal(reanchor) + if err != nil { + t.Fatal(err) + } + call := func() playback.DecisionResponseV3 { + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", started.SessionID) + rr := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response + } + first := call() + second := call() + for index, response := range []playback.DecisionResponseV3{first, second} { + if response.PlaybackPlan == nil || response.SessionID != started.SessionID || + response.PlaybackPlan.SessionID != started.SessionID || + response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID || + response.PlaybackPlan.RequestedMediaFileID != started.PlaybackPlan.RequestedMediaFileID || + response.PlaybackPlan.EffectiveMediaFileID != started.PlaybackPlan.EffectiveMediaFileID || + !sameSelectedTracksV3(response.PlaybackPlan.SelectedTracks, started.PlaybackPlan.SelectedTracks) || + response.PlaybackPlan.Timeline.SourceStartSeconds != 321 || + playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil) != currentKey { + t.Fatalf("reanchored response %d = %#v", index, response.PlaybackPlan) + } + if response.PlaybackPlan.Subtitle.Artifact == nil || + response.PlaybackPlan.Subtitle.Artifact.Format != started.PlaybackPlan.Subtitle.Artifact.Format || + response.PlaybackPlan.Subtitle.Artifact.MIMEType != started.PlaybackPlan.Subtitle.Artifact.MIMEType { + t.Fatalf("reanchored response %d changed the ASS artifact: %#v", index, response.PlaybackPlan.Subtitle) + } + if !playback.HasFeatureV3(response.ServerFeatures, playback.FeatureSeekReanchorV3) { + t.Fatalf("reanchored response %d omitted %q: %#v", index, playback.FeatureSeekReanchorV3, response.ServerFeatures) + } + } + liveSessionManager := handler.sessionMgr + handler.sessionMgr = playback.NewSessionManager(0, 0) + restartReplayReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + restartReplayReq = withPlaybackRouteParam(restartReplayReq, "session_id", started.SessionID) + restartReplayRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(restartReplayRR, restartReplayReq) + handler.sessionMgr = liveSessionManager + if restartReplayRR.Code != http.StatusNotFound || !strings.Contains(restartReplayRR.Body.String(), playbackSessionNotFoundErrorCode) { + t.Fatalf("restart replay status = %d, body = %s", restartReplayRR.Code, restartReplayRR.Body.String()) + } + session, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + if session.Position != 321 || !session.IsPaused { + t.Fatalf("session progress = (%v, paused=%v), want (321, paused=true)", session.Position, session.IsPaused) + } + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if record.CurrentPlanID != started.PlaybackPlan.PlanID || record.EffectiveMediaFileID != file.ID || + record.NormalizedRequest.StartPosition == nil || *record.NormalizedRequest.StartPosition != 321 { + t.Fatalf("stored reanchor = %#v", record) + } + + mismatch := reanchor + mismatch.ReplanRequestID = "seek-reanchor-mismatch-0001" + mismatch.QualityPreference = "480p" + mismatchBody, err := json.Marshal(mismatch) + if err != nil { + t.Fatal(err) + } + mismatchReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(mismatchBody))).WithContext(newAuthorizedPlaybackContext()) + mismatchReq = withPlaybackRouteParam(mismatchReq, "session_id", started.SessionID) + mismatchRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(mismatchRR, mismatchReq) + if mismatchRR.Code != http.StatusOK { + t.Fatalf("mismatch status = %d, body = %s", mismatchRR.Code, mismatchRR.Body.String()) + } + var mismatchResponse playback.DecisionResponseV3 + if err := json.Unmarshal(mismatchRR.Body.Bytes(), &mismatchResponse); err != nil { + t.Fatal(err) + } + if mismatchResponse.Terminal == nil || mismatchResponse.Terminal.Reason != "seek_reanchor_intent_mismatch" { + t.Fatalf("mismatch response = %#v", mismatchResponse) + } + recordAfterMismatch, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if recordAfterMismatch.CurrentPlanID != record.CurrentPlanID || recordAfterMismatch.NormalizedRequest.StartPosition == nil || *recordAfterMismatch.NormalizedRequest.StartPosition != 321 { + t.Fatalf("intent mismatch changed stored route: before=%#v after=%#v", record, recordAfterMismatch) + } + + newer := reanchor + newer.ReplanRequestID = "seek-reanchor-0002" + newer.PositionSeconds = 500 + newerBody, err := json.Marshal(newer) + if err != nil { + t.Fatal(err) + } + newerReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(newerBody))).WithContext(newAuthorizedPlaybackContext()) + newerReq = withPlaybackRouteParam(newerReq, "session_id", started.SessionID) + newerRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(newerRR, newerReq) + if newerRR.Code != http.StatusOK { + t.Fatalf("newer reanchor status = %d, body = %s", newerRR.Code, newerRR.Body.String()) + } + + staleRetryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + staleRetryReq = withPlaybackRouteParam(staleRetryReq, "session_id", started.SessionID) + staleRetryRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(staleRetryRR, staleRetryReq) + if staleRetryRR.Code != http.StatusConflict || !strings.Contains(staleRetryRR.Body.String(), "stale_playback_plan") { + t.Fatalf("stale reanchor replay status = %d, body = %s", staleRetryRR.Code, staleRetryRR.Body.String()) + } + latestRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if latestRecord.CurrentReplanRequestID != newer.ReplanRequestID || latestRecord.NormalizedRequest.StartPosition == nil || *latestRecord.NormalizedRequest.StartPosition != newer.PositionSeconds { + t.Fatalf("stale replay changed latest reanchor: %#v", latestRecord) + } + // Simulate a rolling-deploy writer which updated the current plan but did + // not know about CurrentReplanRequestID. The durable plan comparison must + // still reject A's cached response after B became active. + mixedWriterRecord := *latestRecord + mixedWriterRecord.CurrentReplanRequestID = reanchor.ReplanRequestID + handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), mixedWriterRecord) + mixedWriterRetryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + mixedWriterRetryReq = withPlaybackRouteParam(mixedWriterRetryReq, "session_id", started.SessionID) + mixedWriterRetryRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(mixedWriterRetryRR, mixedWriterRetryReq) + if mixedWriterRetryRR.Code != http.StatusConflict || !strings.Contains(mixedWriterRetryRR.Body.String(), "stale_playback_plan") { + t.Fatalf("mixed-writer stale replay status = %d, body = %s", mixedWriterRetryRR.Code, mixedWriterRetryRR.Body.String()) + } + + beyondEnd := newer + beyondEnd.ReplanRequestID = "seek-reanchor-beyond-end-0001" + beyondEnd.PositionSeconds = float64(file.Duration) + 1 + beyondBody, err := json.Marshal(beyondEnd) + if err != nil { + t.Fatal(err) + } + beyondReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(beyondBody))).WithContext(newAuthorizedPlaybackContext()) + beyondReq = withPlaybackRouteParam(beyondReq, "session_id", started.SessionID) + beyondRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(beyondRR, beyondReq) + if beyondRR.Code != http.StatusOK || !strings.Contains(beyondRR.Body.String(), "invalid_seek_position") { + t.Fatalf("beyond-end reanchor status = %d, body = %s", beyondRR.Code, beyondRR.Body.String()) + } + + missingSince := time.Now() + file.MissingSince = &missingSince + missing := newer + missing.ReplanRequestID = "seek-reanchor-missing-source-0001" + missing.PositionSeconds = 600 + missingBody, err := json.Marshal(missing) + if err != nil { + t.Fatal(err) + } + missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(missingBody))).WithContext(newAuthorizedPlaybackContext()) + missingReq = withPlaybackRouteParam(missingReq, "session_id", started.SessionID) + missingRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(missingRR, missingReq) + if missingRR.Code != http.StatusOK || !strings.Contains(missingRR.Body.String(), "source_unavailable") { + t.Fatalf("missing-source reanchor status = %d, body = %s", missingRR.Code, missingRR.Body.String()) + } + unchanged, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if unchanged.CurrentPlanID != latestRecord.CurrentPlanID || unchanged.EffectiveMediaFileID != latestRecord.EffectiveMediaFileID || + unchanged.NormalizedRequest.StartPosition == nil || *unchanged.NormalizedRequest.StartPosition != newer.PositionSeconds { + t.Fatalf("rejected seeks changed the active route: before=%#v after=%#v", latestRecord, unchanged) + } +} + +func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) { + source := v3HandlerFixtureFile(t) + source.Resolution = "2160p" + source.Bitrate = 32_000 + source.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...) + source.VideoTracks[0].Level = 51 + source.VideoTracks[0].Width = 3840 + source.VideoTracks[0].Height = 2160 + source.VideoTracks[0].Bitrate = 32_000 + + alternateValue := *source + alternate := &alternateValue + alternate.ID = 84 + alternate.Resolution = "1080p" + alternate.Bitrate = 8_000 + alternate.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...) + alternate.VideoTracks[0].Level = 41 + alternate.VideoTracks[0].Width = 1920 + alternate.VideoTracks[0].Height = 1080 + alternate.VideoTracks[0].Bitrate = 8_000 + + manager := playback.NewSessionManager(0, 0) + files := map[int]*models.MediaFile{ + source.ID: source, alternate.ID: alternate, + } + handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files}) + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{ + source.ContentID: {source, alternate}, + }} + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "false"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + + startRequest := v3HandlerStartRequest() + startRequest.QualityPreference = "auto" + startRequest.Capabilities.MaxResolution = "2160p" + startRequest.Capabilities.VideoDecode[0].Levels = []int{51} + startRequest.Capabilities.VideoDecode[0].MaxWidth = 3840 + startRequest.Capabilities.VideoDecode[0].MaxHeight = 2160 + startRequest.Capabilities.VideoDecode[0].MaxBitrateKbps = 50_000 + startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true} + + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != source.ID { + t.Fatalf("initial 4K plan = %#v", started.PlaybackPlan) + } + if err := manager.UpdateProgress(started.SessionID, 15, true); err != nil { + t.Fatal(err) + } + + seekCapabilities := startRequest.Capabilities + seekCapabilities.VideoDecode = append([]playback.VideoDecodeCapabilityV3(nil), startRequest.Capabilities.VideoDecode...) + seekCapabilities.MaxResolution = "1080p" + seekCapabilities.VideoDecode[0].MaxWidth = 1920 + seekCapabilities.VideoDecode[0].MaxHeight = 1080 + seekCapabilities.VideoDecode[0].MaxBitrateKbps = 20_000 + seekContext := startRequest.ClientPlaybackContext + seekContext.Device.Model = "request-only-model" + currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + staleClientKey := "v3:0000000000000000" + replan := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, + Operation: playback.ReplanOperationSeekFailureRecoveryV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "seek-failure-recovery-0001", + FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "plan-attempt-seek-failure-0001", + PlanAttemptKey: staleClientKey, + AttemptedPlanKeys: nil, + AttemptCount: 1, + QualityPreference: startRequest.QualityPreference, + PositionSeconds: 417, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: started.PlaybackPlan.SelectedTracks, + Failure: playback.FailureV3{Classification: "decoder_failure", Message: "reanchored route failed"}, + Capabilities: seekCapabilities, + ClientPlaybackContext: seekContext, + } + body, err := json.Marshal(replan) + if err != nil { + t.Fatal(err) + } + replanReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + replanReq = withPlaybackRouteParam(replanReq, "session_id", started.SessionID) + replanRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(replanRR, replanReq) + if replanRR.Code != http.StatusOK { + t.Fatalf("replan status = %d, body = %s", replanRR.Code, replanRR.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(replanRR.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.PlaybackPlan == nil || response.Terminal != nil || + response.PlaybackPlan.RequestedMediaFileID != source.ID || response.PlaybackPlan.EffectiveMediaFileID != source.ID { + t.Fatalf("failed seek did not recover on the pinned media version: %#v", response) + } + if response.PlaybackPlan.Delivery == playback.DeliveryTranscodeHLSV3 || + response.PlaybackPlan.EffectiveRecipe.Width == nil || *response.PlaybackPlan.EffectiveRecipe.Width != 3840 || + response.PlaybackPlan.EffectiveRecipe.Height == nil || *response.PlaybackPlan.EffectiveRecipe.Height != 2160 { + t.Fatalf("failed seek downgraded or video-transcoded the pinned 4K source: %#v", response.PlaybackPlan) + } + if playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil) == currentKey { + t.Fatalf("failed seek retried the failed route: %#v", response.PlaybackPlan) + } + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if record.EffectiveMediaFileID != source.ID || record.CurrentPlan.EffectiveMediaFileID != source.ID { + t.Fatalf("failed seek changed the durable media version: %#v", record) + } + if record.NormalizedRequest.Capabilities.MaxResolution != startRequest.Capabilities.MaxResolution || + record.NormalizedRequest.ClientPlaybackContext.Device.Model != startRequest.ClientPlaybackContext.Device.Model { + t.Fatalf("failed seek accepted request-only capability evidence: %#v", record.NormalizedRequest) + } + session, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + if session.Position != replan.PositionSeconds || !session.IsPaused { + t.Fatalf("session progress = (%v, paused=%v), want (%v, paused=true)", session.Position, session.IsPaused, replan.PositionSeconds) + } + + delete(files, source.ID) + for index, operation := range []playback.ReplanOperationV3{ + playback.ReplanOperationSeekReanchorV3, + playback.ReplanOperationSeekFailureRecoveryV3, + } { + missing := replan + missing.Operation = operation + missing.ReplanRequestID = fmt.Sprintf("seek-missing-current-%04d", index) + missing.FailedPlanID = response.PlaybackPlan.PlanID + missing.PlanAttemptKey = playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + missing.AttemptedPlanKeys = []string{missing.PlanAttemptKey} + missing.SelectedTracks = response.PlaybackPlan.SelectedTracks + missing.PositionSeconds++ + if operation == playback.ReplanOperationSeekReanchorV3 { + missing.Failure = playback.FailureV3{} + } + missingBody, err := json.Marshal(missing) + if err != nil { + t.Fatal(err) + } + missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(missingBody))).WithContext(newAuthorizedPlaybackContext()) + missingReq = withPlaybackRouteParam(missingReq, "session_id", started.SessionID) + missingRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(missingRR, missingReq) + if missingRR.Code != http.StatusOK || !strings.Contains(missingRR.Body.String(), "source_unavailable") { + t.Fatalf("missing current %s status = %d, body = %s", operation, missingRR.Code, missingRR.Body.String()) + } + } + finalRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + finalSession, err := manager.GetSession(started.SessionID) + if err != nil { + t.Fatal(err) + } + if finalRecord.EffectiveMediaFileID != source.ID || finalRecord.CurrentPlan.EffectiveMediaFileID != source.ID || finalSession.MediaFileID != source.ID { + t.Fatalf("missing 4K source fell through to alternate: record=%#v session=%#v", finalRecord, finalSession) + } +} + +func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGone(t *testing.T) { + effective := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: map[int]*models.MediaFile{effective.ID: effective}}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + + startRequest := v3HandlerStartRequest() + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil || started.PlaybackPlan == nil { + t.Fatalf("start response: err=%v response=%#v", err, started) + } + + const missingRequestedID = 84 + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + record.RequestedMediaFileID = missingRequestedID + record.NormalizedRequest.FileID = missingRequestedID + record.CurrentPlan.RequestedMediaFileID = missingRequestedID + record.CurrentPlan.PlanID = playback.DeterministicPlanIDV3( + record.PlaybackAttemptID, + missingRequestedID, + effective.ID, + record.CurrentPlan, + ) + record.CurrentPlanID = record.CurrentPlan.PlanID + handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record) + + currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil) + reanchor := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, + Operation: playback.ReplanOperationSeekReanchorV3, + PlaybackAttemptID: record.PlaybackAttemptID, + ReplanRequestID: "seek-missing-requested-0001", + FailedPlanID: record.CurrentPlanID, + PlanAttemptID: "plan-attempt-missing-requested-0001", + PlanAttemptKey: currentKey, + AttemptedPlanKeys: []string{currentKey}, + AttemptCount: 1, + QualityPreference: record.NormalizedRequest.QualityPreference, + PositionSeconds: 300, + OutputRouteGeneration: record.NormalizedRequest.OutputRouteGeneration, + SelectedTracks: record.CurrentPlan.SelectedTracks, + Capabilities: record.NormalizedRequest.Capabilities, + ClientPlaybackContext: record.NormalizedRequest.ClientPlaybackContext, + } + body, err := json.Marshal(reanchor) + if err != nil { + t.Fatal(err) + } + reanchorReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + reanchorReq = withPlaybackRouteParam(reanchorReq, "session_id", started.SessionID) + reanchorRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(reanchorRR, reanchorReq) + if reanchorRR.Code != http.StatusOK { + t.Fatalf("reanchor status = %d, body = %s", reanchorRR.Code, reanchorRR.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(reanchorRR.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.PlaybackPlan == nil || response.PlaybackPlan.RequestedMediaFileID != missingRequestedID || + response.PlaybackPlan.EffectiveMediaFileID != effective.ID || response.PlaybackPlan.Timeline.SourceStartSeconds != reanchor.PositionSeconds { + t.Fatalf("reanchor did not retain the effective edition: %#v", response) + } + + current, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + outputContext := current.NormalizedRequest.ClientPlaybackContext + outputContext.Output.OutputRouteGeneration++ + currentKey = playback.PlanAttemptKeyV3(current.CurrentPlan, current.NormalizedRequest.OutputRouteGeneration, nil) + ordinary := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, + PlaybackAttemptID: current.PlaybackAttemptID, + ReplanRequestID: "ordinary-missing-requested-0001", + FailedPlanID: current.CurrentPlanID, + PlanAttemptID: "plan-attempt-ordinary-missing-0001", + PlanAttemptKey: currentKey, + AttemptedPlanKeys: []string{currentKey}, + AttemptCount: 1, + QualityPreference: current.NormalizedRequest.QualityPreference, + PositionSeconds: 320, + OutputRouteGeneration: outputContext.Output.OutputRouteGeneration, + SelectedTracks: current.CurrentPlan.SelectedTracks, + Failure: playback.FailureV3{Classification: "output_route_changed"}, + Capabilities: current.NormalizedRequest.Capabilities, + ClientPlaybackContext: outputContext, + } + ordinaryBody, err := json.Marshal(ordinary) + if err != nil { + t.Fatal(err) + } + ordinaryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(ordinaryBody))).WithContext(newAuthorizedPlaybackContext()) + ordinaryReq = withPlaybackRouteParam(ordinaryReq, "session_id", started.SessionID) + ordinaryRR := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(ordinaryRR, ordinaryReq) + if ordinaryRR.Code != http.StatusOK { + t.Fatalf("ordinary replan status = %d, body = %s", ordinaryRR.Code, ordinaryRR.Body.String()) + } + var ordinaryResponse playback.DecisionResponseV3 + if err := json.Unmarshal(ordinaryRR.Body.Bytes(), &ordinaryResponse); err != nil { + t.Fatal(err) + } + if ordinaryResponse.PlaybackPlan == nil || ordinaryResponse.PlaybackPlan.RequestedMediaFileID != missingRequestedID || + ordinaryResponse.PlaybackPlan.EffectiveMediaFileID != effective.ID { + t.Fatalf("ordinary replan lost the requested/effective split: %#v", ordinaryResponse) + } +} + +func TestValidateSeekRecoveryRequestV3PinsCurrentIntent(t *testing.T) { + audioIndex := 0 + start := v3HandlerStartRequest() + start.AudioTrackID = playback.TrackIDV3(start.FileID, "audio", audioIndex) + start.AudioTrackIndex = &audioIndex + selected := playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: start.AudioTrackID, Index: &audioIndex}} + plan := playback.PlanV3{ + PlanID: "plan:seek-current", + RequestedMediaFileID: start.FileID, + EffectiveMediaFileID: start.FileID, + SelectedTracks: selected, + } + record := &playback.AttemptRecordV3{ + RequestedMediaFileID: start.FileID, + EffectiveMediaFileID: start.FileID, + CurrentPlanID: plan.PlanID, + CurrentPlan: plan, + NormalizedRequest: start, + } + request := playback.ReplanRequestV3{ + Operation: playback.ReplanOperationSeekReanchorV3, + QualityPreference: start.QualityPreference, + OutputRouteGeneration: start.OutputRouteGeneration, + SelectedTracks: selected, + } + if err := validateSeekRecoveryRequestV3(record, request); err != nil { + t.Fatalf("valid seek reanchor guard: %v", err) + } + + // Capabilities and client context are structurally validated by the request + // decoder but are not route inputs for a same-recipe reanchor. + request.Capabilities.CodecsVideo = []string{"av1"} + request.ClientPlaybackContext.Device.Model = "changed-client-claim" + if err := validateSeekRecoveryRequestV3(record, request); err != nil { + t.Fatalf("ignored client evidence changed stored intent: %v", err) + } + + tests := []struct { + name string + mutate func(*playback.ReplanRequestV3) + }{ + {name: "quality", mutate: func(value *playback.ReplanRequestV3) { value.QualityPreference = "480p" }}, + {name: "output route", mutate: func(value *playback.ReplanRequestV3) { value.OutputRouteGeneration++ }}, + {name: "audio track", mutate: func(value *playback.ReplanRequestV3) { + index := 1 + value.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: playback.TrackIDV3(start.FileID, "audio", index), Index: &index} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := request + test.mutate(&candidate) + if err := validateSeekRecoveryRequestV3(record, candidate); err == nil { + t.Fatalf("%s change was accepted", test.name) + } + }) + } +} + +func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) { + audioIndex := 0 + frameRate := 23.976 + audioChannels := 2 + current := playback.PlanV3{ + PlanID: "plan:seek-current", + Delivery: playback.DeliveryOriginalHTTPV3, + Engine: playback.EngineMedia3DirectV3, + Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshNoneV3}, + SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}}, + EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", FrameRate: &frameRate, AudioChannels: &audioChannels, AudioLayout: "stereo"}, + Claims: playback.ValidationClaimsV3{Video: playback.VideoClaimsV3{HDR10: true}}, + Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3, TrackID: playback.TrackIDV3(42, "subtitle", 0), Artifact: &playback.SubtitleArtifactV3{ + MIMEType: "text/x-ssa", Format: "ass", + }}, + Transformations: []playback.TransformationV3{{Name: "subtitle-convert", Executor: "ffmpeg", RecipeVersion: "1", ValidatedClaims: []string{"ass"}}}, + AppliedQuirks: []playback.AppliedQuirkV3{{ID: "quirk-1", RegistryRevision: "1", Action: "force-remux"}}, + RuntimeCorrections: []string{"pcm_fallback"}, + RequestedMediaFileID: 42, + EffectiveMediaFileID: 42, + SubtitleFidelityPolicy: "preserve_styling", + } + record := &playback.AttemptRecordV3{ + RequestedMediaFileID: 42, + EffectiveMediaFileID: 42, + CurrentPlanID: current.PlanID, + CurrentPlan: current, + NormalizedRequest: playback.StartRequestV3{OutputRouteGeneration: 9}, + } + candidate := current + candidate.Timeline.SourceStartSeconds = 321 + if err := validateSeekReanchorPlanV3(record, &candidate); err != nil { + t.Fatalf("timeline-only change rejected: %v", err) + } + + tests := []struct { + name string + mutate func(*playback.PlanV3) + }{ + {name: "delivery recipe", mutate: func(value *playback.PlanV3) { value.Delivery = playback.DeliveryRemuxProgressiveV3 }}, + {name: "engine", mutate: func(value *playback.PlanV3) { value.Engine = playback.EngineMedia3HLSV3 }}, + {name: "stream MIME", mutate: func(value *playback.PlanV3) { value.Stream.MIMEType = "application/x-mpegURL" }}, + {name: "header refresh", mutate: func(value *playback.PlanV3) { value.Stream.HeaderRefresh = playback.HeaderRefreshSessionV3 }}, + {name: "frame rate", mutate: func(value *playback.PlanV3) { + changed := 24.0 + value.EffectiveRecipe.FrameRate = &changed + }}, + {name: "audio channels", mutate: func(value *playback.PlanV3) { + changed := 6 + value.EffectiveRecipe.AudioChannels = &changed + }}, + {name: "audio layout", mutate: func(value *playback.PlanV3) { value.EffectiveRecipe.AudioLayout = "5.1" }}, + {name: "claims", mutate: func(value *playback.PlanV3) { value.Claims.Video.HDR10 = false }}, + {name: "subtitle artifact MIME", mutate: func(value *playback.PlanV3) { + copy := *value.Subtitle.Artifact + copy.MIMEType = "text/vtt" + value.Subtitle.Artifact = © + }}, + {name: "subtitle artifact format", mutate: func(value *playback.PlanV3) { + copy := *value.Subtitle.Artifact + copy.Format = "vtt" + value.Subtitle.Artifact = © + }}, + {name: "subtitle fidelity", mutate: func(value *playback.PlanV3) { value.SubtitleFidelityPolicy = "compatibility" }}, + {name: "transformation claims", mutate: func(value *playback.PlanV3) { + value.Transformations = append([]playback.TransformationV3(nil), value.Transformations...) + value.Transformations[0].ValidatedClaims = []string{"vtt"} + }}, + {name: "quirk action", mutate: func(value *playback.PlanV3) { + value.AppliedQuirks = append([]playback.AppliedQuirkV3(nil), value.AppliedQuirks...) + value.AppliedQuirks[0].Action = "disable-passthrough" + }}, + {name: "effective version", mutate: func(value *playback.PlanV3) { value.EffectiveMediaFileID = 84 }}, + {name: "track", mutate: func(value *playback.PlanV3) { + index := 1 + value.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", index), Index: &index} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + drifted := candidate + test.mutate(&drifted) + if err := validateSeekReanchorPlanV3(record, &drifted); err == nil { + t.Fatalf("%s drift was accepted", test.name) + } + }) + } +} + +func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) { + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.JWTSecret = "test-secret" + session := &playback.Session{ID: "session-progressive", UserID: 7, ProfileID: "profile-1", MediaFileID: 42, PlayMethod: playback.PlayRemux, BasePlayMethod: playback.PlayRemux, AudioTrackIndex: 0} + for index, origin := range []float64{321.25, 654.5} { + plan := &playback.PlanV3{ + PlanID: "plan:progressive", + Delivery: playback.DeliveryRemuxProgressiveV3, + EffectiveMediaFileID: 42, + Timeline: playback.TimelineV3{SourceStartSeconds: origin, PlayerStartSeconds: origin, CanSeekAnywhere: true, SeekRestoration: "player_position"}, + } + transport := handler.prepareIdentityTransportV3(session, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux}) + + parsed, err := url.Parse(transport.url) + if err != nil { + transport.rollback() + t.Fatal(err) + } + if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(origin, 'f', -1, 64) { + transport.rollback() + t.Fatalf("progressive reanchor URL %d = %q", index, transport.url) + } + if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.StreamOriginSeconds != origin || + plan.Timeline.TimelineOffsetSeconds != origin || plan.Timeline.CanSeekAnywhere || + plan.Timeline.SeekWindowStartSeconds == nil || *plan.Timeline.SeekWindowStartSeconds != origin || + plan.Timeline.SeekWindowEndSeconds != nil || plan.Timeline.SeekRestoration != "source_position" { + transport.rollback() + t.Fatalf("progressive reanchor timeline %d = %#v", index, plan.Timeline) + } + transport.rollback() + } +} + +func TestPrepareTransportV3RejectsNodeMissingRequiredTransformation(t *testing.T) { + startHits := 0 + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/hw-capabilities": + writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"}}}) + case "/transcode/start": + startHits++ + w.WriteHeader(http.StatusAccepted) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer remote.Close() + + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}} + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.local_transcode_fallback": "false"}} + plan := &playback.PlanV3{ + PlanID: "plan:remote-capability", + Delivery: playback.DeliveryTranscodeHLSV3, + Transformations: []playback.TransformationV3{ + {Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}, + {Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}, + }, + } + request := httptest.NewRequest(http.MethodPost, "/", nil) + _, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-capability"}, v3HandlerFixtureFile(t), playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode, TargetVideoCodec: "h264", TargetAudioCodec: "aac"}) + if transportErr == nil || transportErr.reason != "transcode_node_capability_unavailable" { + t.Fatalf("transport error = %#v", transportErr) + } + if startHits != 0 { + t.Fatalf("incompatible node received %d start requests", startHits) + } +} + +func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) { + var startRequest transcodenode.TranscodeStartRequest + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities": + writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{ + {Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}, + {Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}, + }}) + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil { + t.Errorf("decode remote start: %v", err) + } + writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"}) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer remote.Close() + + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.JWTSecret = "test-secret" + handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}} + plan := &playback.PlanV3{ + PlanID: "plan:remote-ready", + Delivery: playback.DeliveryTranscodeHLSV3, + Transformations: []playback.TransformationV3{ + {Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}, + {Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}, + }, + } + request := httptest.NewRequest(http.MethodPost, "/", nil) + transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-ready", UserID: 7, ProfileID: "profile-1"}, v3HandlerFixtureFile(t), playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode, TargetVideoCodec: "h264", TargetAudioCodec: "aac"}) + if transportErr != nil { + t.Fatalf("prepare remote transport: %v", transportErr) + } + defer transport.rollback() + if !startRequest.RequireReady { + t.Fatal("protocol-v3 remote start did not require manifest readiness") + } +} + +func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(`{"protocol_version":99,"file_id":42,"profile_id":"profile-1","play_method":"direct"}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playbackSessionResponse + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.SessionID == "" || response.PlayMethod != string(playback.PlayDirect) { + t.Fatalf("legacy response = %#v", response) + } +} + +func TestHandleStartPlaybackLegacyBranchPreservesTrailingBodyBehavior(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"} trailing`)).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) { + copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} + copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600) + if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere || + copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 || + copyPlan.Timeline.SeekWindowEndSeconds == nil || *copyPlan.Timeline.SeekWindowEndSeconds != 600 || + copyPlan.Timeline.SeekRestoration != "source_position" { + t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment) + } + + encodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} + encodeSeek, encodeSegment := configureHLSTimelineV3(encodePlan, "h264", 2, 600) + if encodeSeek != 16 || encodeSegment != 8 || encodePlan.Timeline.StreamOriginSeconds != 0 || encodePlan.Timeline.TimelineOffsetSeconds != 0 || encodePlan.Timeline.PlayerStartSeconds != 17.3 || !encodePlan.Timeline.CanSeekAnywhere || + encodePlan.Timeline.SeekWindowStartSeconds != nil || encodePlan.Timeline.SeekWindowEndSeconds != nil || + encodePlan.Timeline.SeekRestoration != "player_position" { + t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment) + } + unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} + configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0) + if unknownDurationPlan.Timeline.CanSeekAnywhere { + t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline) + } +} + +func TestTransportGenerationV3IsUniqueAndSessionScoped(t *testing.T) { + first := transportGenerationV3("session-1", "plan:abcdef") + second := transportGenerationV3("session-1", "plan:abcdef") + if first == second || !strings.HasPrefix(first, "session-1-abcdef-") || !strings.HasPrefix(second, "session-1-abcdef-") { + t.Fatalf("generations = %q, %q", first, second) + } +} + +func TestRemuxDVModeForPlanV3ExecutesProfile8Strip(t *testing.T) { + plan := &playback.PlanV3{Source: playback.SourceDescriptorV3{DVProfile: 8}, Transformations: []playback.TransformationV3{{Name: "server_dv7_to_hdr10"}}} + if got := remuxDVModeForPlanV3(plan); got != playback.RemuxDVStripToHDR10V3 { + t.Fatalf("mode = %q", got) + } +} + +func TestHandlePlaybackRouteEventV3RejectsWhileDisabled(t *testing.T) { + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}} + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/route-events", strings.NewReader(`{}`)).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandlePlaybackRouteEventV3(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + +func TestSanitizeDiagnosticsV3PreservesPlayerFailureEvidence(t *testing.T) { + got := sanitizeDiagnosticsV3(map[string]string{ + "error_code": "2004", + "error_code_name": "ERROR_CODE_PARSING_CONTAINER_MALFORMED", + "error_cause": "ParserException", + "network_transport": "wifi", + "network_metered": "true", + "network_validated": "true", + "bandwidth_estimate_kbps": "3500", + "link_downstream_kbps": "5000", + "target_source_position_seconds": "321.5", + "reason": "seek_reanchor", + "message": "must not be persisted", + }) + if got["error_code"] != "2004" || got["error_code_name"] == "" || got["error_cause"] != "ParserException" { + t.Fatalf("failure diagnostics = %#v", got) + } + if _, ok := got["message"]; ok { + t.Fatalf("unapproved message persisted: %#v", got) + } + for _, key := range []string{"network_transport", "network_metered", "network_validated", "bandwidth_estimate_kbps", "link_downstream_kbps", "target_source_position_seconds", "reason"} { + if got[key] == "" { + t.Errorf("client diagnostic %q was stripped: %#v", key, got) + } + } +} + +func TestRouteEventV3AcceptsAndroidSeekEvents(t *testing.T) { + base := playback.RouteEventV3{ + ProtocolVersion: playback.ProtocolV3, + PlaybackAttemptID: "attempt-route-0001", + OutputRouteGeneration: 1, + } + for _, event := range []string{playback.RouteEventSeekReanchorRequestedV3, playback.RouteEventSeekReanchoredV3} { + candidate := base + candidate.Event = event + if !validRouteEventV3(candidate) { + t.Errorf("Android route event %q was rejected", event) + } + } +} + +func TestRemapSubtitleSelectionV3RejectsNegativeIndex(t *testing.T) { + index := -1 + request := playback.StartRequestV3{SubtitleTrackIndex: &index} + source := &models.MediaFile{ID: 1, ExternalSubtitles: []models.ExternalSubtitle{{Language: "eng", Format: "srt"}}} + target := &models.MediaFile{ID: 2, ExternalSubtitles: []models.ExternalSubtitle{{Language: "eng", Format: "srt"}}} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + if err := handler.remapSubtitleSelectionV3(context.Background(), source, target, &request); err == nil { + t.Fatal("negative subtitle index was accepted") + } +} + +func TestRouteEventV3HasPerUserLimitAcrossAttemptIDs(t *testing.T) { + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + for i := 0; i < 600; i++ { + attemptID := "attempt-" + strconv.Itoa(i/100) + if !handler.allowRouteEventV3(7, attemptID) { + t.Fatalf("event %d was rejected before the user limit", i) + } + } + if handler.allowRouteEventV3(7, "attempt-rotated") { + t.Fatal("rotating attempt IDs bypassed the per-user limit") + } +} + +func TestLegacyShadowRequestV3ProducesExplicitDetailedInference(t *testing.T) { + file := v3HandlerFixtureFile(t) + legacy := startPlaybackRequest{FileID: file.ID, ProfileID: "profile-1", CodecsVideo: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p"} + request := legacyShadowRequestV3(legacy, file, 0, "session-1234") + if _, err := request.NormalizeAndValidate(); err != nil { + t.Fatalf("shadow request validation: %v", err) + } + if len(request.Capabilities.VideoDecode) != 1 || !request.Capabilities.VideoDecode[0].Hardware || !playback.HasFeatureV3(request.ClientFeatures, playback.FeatureDetailedDecodeV3) { + t.Fatalf("shadow request = %#v", request) + } +} + +func v3HandlerFixtureFile(t *testing.T) *models.MediaFile { + t.Helper() + return &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mp4"), Container: "mp4", CodecVideo: "h264", CodecAudio: "aac", Resolution: "1080p", Bitrate: 8_000, AudioChannels: 2, Duration: 3600, VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}} +} + +func v3HandlerStartRequest() playback.StartRequestV3 { + return playback.StartRequestV3{ProtocolVersion: playback.ProtocolV3, ClientFeatures: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}, FileID: 42, ProfileID: "profile-1", PlaybackAttemptID: "attempt-handler-0001", QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3, OutputRouteGeneration: 1, Capabilities: playback.ClientCodecCapabilitiesV3{CodecsVideo: []string{"h264"}, CodecsVideoHardware: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p", VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}}, ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}, Platform: "android", FormFactor: "tv", AppVersion: "test", Output: playback.OutputContextV3{OutputRouteGeneration: 1}, Engines: map[string]playback.EngineCapabilityV3{string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}}}} +} + +func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + return string(body) +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 1e6b0e38..ef198fc3 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -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", "*") diff --git a/internal/api/handlers/stream_test.go b/internal/api/handlers/stream_test.go index 507be166..5aefc50c 100644 --- a/internal/api/handlers/stream_test.go +++ b/internal/api/handlers/stream_test.go @@ -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} diff --git a/internal/api/handlers/system.go b/internal/api/handlers/system.go index 175d8e8d..76944f0e 100644 --- a/internal/api/handlers/system.go +++ b/internal/api/handlers/system.go @@ -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) } diff --git a/internal/api/router.go b/internal/api/router.go index b508072f..bb8cebcf 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/models/media.go b/internal/models/media.go index 1c6caa40..02a41b40 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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. diff --git a/internal/models/media_test.go b/internal/models/media_test.go index e74c529c..1086f6bf 100644 --- a/internal/models/media_test.go +++ b/internal/models/media_test.go @@ -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) + } + }) + } +} diff --git a/internal/nodepool/planner.go b/internal/nodepool/planner.go index 9f058e56..233978e7 100644 --- a/internal/nodepool/planner.go +++ b/internal/nodepool/planner.go @@ -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. diff --git a/internal/nodepool/planner_test.go b/internal/nodepool/planner_test.go index f2f29b79..4fcbca83 100644 --- a/internal/nodepool/planner_test.go +++ b/internal/nodepool/planner_test.go @@ -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 diff --git a/internal/playback/capabilities_v3.go b/internal/playback/capabilities_v3.go new file mode 100644 index 00000000..f20bcc48 --- /dev/null +++ b/internal/playback/capabilities_v3.go @@ -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 +} diff --git a/internal/playback/device_quirks_v3.go b/internal/playback/device_quirks_v3.go new file mode 100644 index 00000000..8f3545eb --- /dev/null +++ b/internal/playback/device_quirks_v3.go @@ -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" +} diff --git a/internal/playback/device_quirks_v3_test.go b/internal/playback/device_quirks_v3_test.go new file mode 100644 index 00000000..6ff6c8fe --- /dev/null +++ b/internal/playback/device_quirks_v3_test.go @@ -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 +} diff --git a/internal/playback/gpudetect.go b/internal/playback/gpudetect.go index 6d611cd8..468b00fb 100644 --- a/internal/playback/gpudetect.go +++ b/internal/playback/gpudetect.go @@ -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. diff --git a/internal/playback/plan_key_v3.go b/internal/playback/plan_key_v3.go new file mode 100644 index 00000000..9bb18192 --- /dev/null +++ b/internal/playback/plan_key_v3.go @@ -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) +} diff --git a/internal/playback/plan_v3.go b/internal/playback/plan_v3.go new file mode 100644 index 00000000..42a3f9b2 --- /dev/null +++ b/internal/playback/plan_v3.go @@ -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" +} diff --git a/internal/playback/planstore/postgres.go b/internal/playback/planstore/postgres.go new file mode 100644 index 00000000..921ca3b0 --- /dev/null +++ b/internal/playback/planstore/postgres.go @@ -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 +} diff --git a/internal/playback/planstore/postgres_test.go b/internal/playback/planstore/postgres_test.go new file mode 100644 index 00000000..a99d6aad --- /dev/null +++ b/internal/playback/planstore/postgres_test.go @@ -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() + }) +} diff --git a/internal/playback/protocol_store_v3.go b/internal/playback/protocol_store_v3.go new file mode 100644 index 00000000..ed567ea5 --- /dev/null +++ b/internal/playback/protocol_store_v3.go @@ -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 +} diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go new file mode 100644 index 00000000..1effcbbf --- /dev/null +++ b/internal/playback/protocol_v3.go @@ -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) } diff --git a/internal/playback/protocol_v3_session_test.go b/internal/playback/protocol_v3_session_test.go new file mode 100644 index 00000000..f5eeabd7 --- /dev/null +++ b/internal/playback/protocol_v3_session_test.go @@ -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) + } +} diff --git a/internal/playback/protocol_v3_test.go b/internal/playback/protocol_v3_test.go new file mode 100644 index 00000000..57802afc --- /dev/null +++ b/internal/playback/protocol_v3_test.go @@ -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}, + }) +} diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go index 9e97d834..d4d00e0e 100644 --- a/internal/playback/recipecard.go +++ b/internal/playback/recipecard.go @@ -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, } } diff --git a/internal/playback/recipecard_test.go b/internal/playback/recipecard_test.go index c83d926d..258abd73 100644 --- a/internal/playback/recipecard_test.go +++ b/internal/playback/recipecard_test.go @@ -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 || diff --git a/internal/playback/remux.go b/internal/playback/remux.go index f0135f45..bdc0d7c6 100644 --- a/internal/playback/remux.go +++ b/internal/playback/remux.go @@ -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 diff --git a/internal/playback/remux_dv_test.go b/internal/playback/remux_dv_test.go index afa61008..a1e19e20 100644 --- a/internal/playback/remux_dv_test.go +++ b/internal/playback/remux_dv_test.go @@ -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, " ")) + } +} diff --git a/internal/playback/session.go b/internal/playback/session.go index 615b00d0..eeb6c4bb 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -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++ } } diff --git a/internal/playback/session_test.go b/internal/playback/session_test.go index b73a4cbf..ba2a23cc 100644 --- a/internal/playback/session_test.go +++ b/internal/playback/session_test.go @@ -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) + } +} diff --git a/internal/playback/subtitle_policy_v3.go b/internal/playback/subtitle_policy_v3.go new file mode 100644 index 00000000..00a586c6 --- /dev/null +++ b/internal/playback/subtitle_policy_v3.go @@ -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}} +} diff --git a/internal/playback/subtitle_policy_v3_test.go b/internal/playback/subtitle_policy_v3_test.go new file mode 100644 index 00000000..3fce8c3d --- /dev/null +++ b/internal/playback/subtitle_policy_v3_test.go @@ -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) + } + } +} diff --git a/internal/playback/subtitle_stream.go b/internal/playback/subtitle_stream.go index 2f95a13f..d56aa66f 100644 --- a/internal/playback/subtitle_stream.go +++ b/internal/playback/subtitle_stream.go @@ -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 `` // 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" diff --git a/internal/playback/subtitle_stream_test.go b/internal/playback/subtitle_stream_test.go index 26c2bd07..3f7b2d0b 100644 --- a/internal/playback/subtitle_stream_test.go +++ b/internal/playback/subtitle_stream_test.go @@ -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", diff --git a/internal/playback/subtitles.go b/internal/playback/subtitles.go index 75a48c8c..c2aff3d6 100644 --- a/internal/playback/subtitles.go +++ b/internal/playback/subtitles.go @@ -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. diff --git a/internal/playback/testdata/protocol_v3/attempt_keys.json b/internal/playback/testdata/protocol_v3/attempt_keys.json new file mode 100644 index 00000000..d8d6f243 --- /dev/null +++ b/internal/playback/testdata/protocol_v3/attempt_keys.json @@ -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" + } +] diff --git a/internal/playback/testdata/protocol_v3/decision_response.json b/internal/playback/testdata/protocol_v3/decision_response.json new file mode 100644 index 00000000..a76c05dc --- /dev/null +++ b/internal/playback/testdata/protocol_v3/decision_response.json @@ -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" + } +} diff --git a/internal/playback/testdata/protocol_v3/replan_request.json b/internal/playback/testdata/protocol_v3/replan_request.json new file mode 100644 index 00000000..0ec6c927 --- /dev/null +++ b/internal/playback/testdata/protocol_v3/replan_request.json @@ -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 + } + } + } +} diff --git a/internal/playback/testdata/protocol_v3/start_request.json b/internal/playback/testdata/protocol_v3/start_request.json new file mode 100644 index 00000000..716b9159 --- /dev/null +++ b/internal/playback/testdata/protocol_v3/start_request.json @@ -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": []} + } + } +} diff --git a/internal/playback/tracks_v3.go b/internal/playback/tracks_v3.go new file mode 100644 index 00000000..568847ec --- /dev/null +++ b/internal/playback/tracks_v3.go @@ -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 +} diff --git a/internal/playback/tracks_v3_test.go b/internal/playback/tracks_v3_test.go new file mode 100644 index 00000000..d26e407a --- /dev/null +++ b/internal/playback/tracks_v3_test.go @@ -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) + } + } +} diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index 07515fdd..2c1b1b39 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -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) } diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index b72f82aa..b3e011b9 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -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", diff --git a/internal/playback/transcode_cleanup.go b/internal/playback/transcode_cleanup.go index 1d9b3487..990b0a32 100644 --- a/internal/playback/transcode_cleanup.go +++ b/internal/playback/transcode_cleanup.go @@ -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 +} diff --git a/internal/playback/transcode_cleanup_test.go b/internal/playback/transcode_cleanup_test.go new file mode 100644 index 00000000..2ae14746 --- /dev/null +++ b/internal/playback/transcode_cleanup_test.go @@ -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) + } + } +} diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index 633c6d6c..c2dc974e 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -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. diff --git a/internal/playback/transformations_v3.go b/internal/playback/transformations_v3.go new file mode 100644 index 00000000..5fd33521 --- /dev/null +++ b/internal/playback/transformations_v3.go @@ -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 +} diff --git a/internal/playback/transformations_v3_test.go b/internal/playback/transformations_v3_test.go new file mode 100644 index 00000000..68136062 --- /dev/null +++ b/internal/playback/transformations_v3_test.go @@ -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) + } + }) + } +} diff --git a/internal/proxy/playback_v3_test.go b/internal/proxy/playback_v3_test.go new file mode 100644 index 00000000..370b8f52 --- /dev/null +++ b/internal/proxy/playback_v3_test.go @@ -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) + } +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 46801c67..9c5562dc 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -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. diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 20d9e088..14831337 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -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) diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 6f89e033..2bc6f94b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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 diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 4bd01b2f..6ad09d45 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -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. diff --git a/internal/streamtoken/token.go b/internal/streamtoken/token.go index f6b78964..fb3c0734 100644 --- a/internal/streamtoken/token.go +++ b/internal/streamtoken/token.go @@ -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. diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 9719775f..8a6576de 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -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) diff --git a/internal/transcodenode/server_test.go b/internal/transcodenode/server_test.go index 5ee91b48..bdcb5246 100644 --- a/internal/transcodenode/server_test.go +++ b/internal/transcodenode/server_test.go @@ -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 diff --git a/migrations/playback_protocol_v3_test.go b/migrations/playback_protocol_v3_test.go new file mode 100644 index 00000000..e44710d0 --- /dev/null +++ b/migrations/playback_protocol_v3_test.go @@ -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) + } + } +} diff --git a/migrations/sql/20260712103000_add_playback_protocol_v3.sql b/migrations/sql/20260712103000_add_playback_protocol_v3.sql new file mode 100644 index 00000000..3930a059 --- /dev/null +++ b/migrations/sql/20260712103000_add_playback_protocol_v3.sql @@ -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 diff --git a/migrations/sql/20260713170000_add_playback_v3_attempt_revision.sql b/migrations/sql/20260713170000_add_playback_v3_attempt_revision.sql new file mode 100644 index 00000000..03928163 --- /dev/null +++ b/migrations/sql/20260713170000_add_playback_v3_attempt_revision.sql @@ -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; diff --git a/scripts/playback_v3_release_queries.sql b/scripts/playback_v3_release_queries.sql new file mode 100644 index 00000000..fb865f79 --- /dev/null +++ b/scripts/playback_v3_release_queries.sql @@ -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;