* docs(settings): define the cross-platform settings contract Turns the audit in #376 into a decision-complete design for how user settings work across the server, bundled web client, Apple clients, and Android clients. Today there are three partial contracts - the server registry, the web client's own manifest, and independently owned key constants in each native client - and they have measurably drifted. The root enabler is that keyUsesUserScope returns true for any unregistered key, so a client can invent a production setting unilaterally and the server stores it as an unvalidated string. The design decides: Ownership. Every production user-facing setting needs a server-owned manifest entry, even when the value is stored only on one client. The single exception is private local.<client>.* diagnostics, bounded by five conditions. Types and scopes. Native JSON values instead of strings. Five remote scopes plus client_local, and each definition declares its own resolution order rather than inheriting a global precedence. Preferences versus restrictions. internal/policy already resolves max_playback_quality and metadata-language limits over the same controls this contract resolves preferences for. Definitions declare constrained_by, the effective response reports the permitted value alongside the user's stored one, and a mutation exceeding a restriction is stored rather than rejected - a capped 4K preference should take effect the day the cap lifts, not be destroyed by it. Compatibility. Widening a scope, adding an enum member, or widening a range is additive and revision-tagged; narrowing anything needs a new key. introduced_in is a manifest revision attached to individual enum members and scopes, not just whole definitions, so a newer client never offers a choice an older server will reject. Rollout. One coordinated breaking release, with no compatibility shim, projection, or client fallback. After the cutover no future setting requires coordination. No settings version check goes in the authenticated middleware and nothing returns 426: deleting the old routes already produces the break, and a gate would be more code in four repos for the same outcome while permanently coupling every endpoint to one subsystem's versioning. Scope placement. Appearance and date/time move from account to profile scope. Account scope was an artifact of pre-profile storage; leaving it there means a household shares one theme and text size, and any non-child profile can restyle everyone else. Read path. Batched context resolution, index requirements, a session-snapshot rule, and a no-regression benchmark gating storage consolidation - profile_series resolution is per-item, so a season view would otherwise issue one request per episode. Verified against the current server, Apple, and Android implementations. Two findings shape it: the unknown-key extension bag is real, and v1 scope reads NOT LOCKED, so removing the legacy surface needs no amendment if it lands before lock. Related to #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical settings contract manifest First implementation step for the cross-platform settings contract (#376). Adds the artifact everything else depends on: the manifest, its JSON Schema, the object value schemas, and a Go loader that validates the whole thing at load time. No routes, no storage, no behavior change — nothing reads this yet. contracts/settings/v1/ holds the artifact at a stable path because clients vendor it and generate bindings from it. The embed directive has to sit beside it (go:embed cannot reach outside its own directory), so that directory is a tiny Go package containing nothing else; loading and validation live in internal/settingscontract. 38 definitions: 35 remote, 3 contract-known client_local. That covers every key the legacy registry accepts, every unregistered key the extension bag was silently accepting from the web client, every unregistered device key Android writes, and the profile preference columns that become settings. Registering the previously-unregistered keys is where the drift shows up, and the manifest records each case in a notes field: - ui_theme, ui_text_scale, ui_text_weight, ui_high_contrast, ui_custom_theme_vars, and ui_custom_css reached the server only because keyUsesUserScope returns true for any unregistered key. They are now typed, renamed to the dotted convention every other key uses, and moved to profile scope per the design. - player.match_frame_rate and player.sleep_timer_default_minutes are written by Android against a server that does not register them, so every write and reset is currently rejected. Registered. - player.next_up_prompt_seconds is Android's alias for playback.next_up_prompt_seconds and does not become a definition; the test matrix pins it as a migration alias. - player.playback_speed is capped at 3.0, matching the server rather than Android's 4.0. - subtitle_appearance becomes playback.subtitle_appearance. Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. Validation is deliberately stricter than the schema can express. Beyond shape, it enforces that a resolution order ends in "default", that it only resolves scopes the definition allows, and — the one most likely to bite — that every writable scope is actually read, so a setting cannot accept writes at a scope it will never honor. Defaults are validated against their own value schema, so a default that violates its own range or enum fails at load. Revision tags are checked to never run ahead of the manifest revision, which is what makes revision-aware client filtering trustworthy. Ceiling and floor policy constraints are rejected on unordered types, where capping would silently do nothing; playback.preferred_quality's enum is therefore ordered ascending. ValidateValue is the single validation path, so the mutation endpoint, the migration, and the manifest's own default checks cannot diverge later. Numbers decode through json.Number so an integer setting rejects 30.5 rather than truncating, and object values validate against their referenced JSON Schema instead of accepting arbitrary JSON the way validateJSONSetting does today. Canonicalization implements RFC 8785 over the value domain the contract uses: sorted keys, no insignificant whitespace, ECMAScript number formatting. The digest is the ETag, and PublicBytes strips maintainer notes so the served manifest never carries internal commentary. Promotes santhosh-tekuri/jsonschema/v6 from indirect to direct. Verification: 124 tests pass across 16 cases; golangci-lint clean; make verify-local-paths passes. Two failures in internal/api/handlers (TestRemoveJellyfinCompatWebDisablesWebSetting, the playback v3 seek recovery test) reproduce unchanged on main and are unrelated. Part of #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): give ui.theme a device override Theme joins text scale, text weight, and high contrast as a profile default with an optional per-device override, resolving profile_device -> profile -> default. The right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning the other three appearance keys already used. All four appearance settings now cascade consistently, which also means one rule to explain in the UI rather than "these three follow the device, that one does not". ui.custom_theme_vars and ui.custom_css stay profile-wide. They are authored styling rather than a contextual preference, so a profile's custom tokens still apply on top of whichever theme a device resolves to. Recorded in the definition notes because it is a visible consequence: vars tuned against a dark theme will sit on top of a light one if a device overrides the theme. Widening those to profile_device later is an additive revision bump if it turns out to matter. Part of #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): tag local appearance caches with their owning account The theme, text scale, text weight, high contrast, custom theme variable and custom CSS caches in localStorage were untagged, so on a shared browser a second account inherited the first account's appearance: with no server value of its own, every fallback resolved to whatever the previous account had stored, and the leftover `silo-theme` key also suppressed the admin-configured default theme for the new account. DateTimeFormatProvider already solved this by stamping its cache with the authenticated user id and refusing another account's values. Extract that mechanism into `createOwnedCache` in utils/storage.ts (where key namespacing lives) and put all three groups behind it, so appearance and custom theme get the same protection instead of a third copy of the rule. - Each group carries its own owner stamp. A shared stamp would be unsafe: the groups are written by hooks nested inside each other, and effects run inner-first, so whichever hook stamped first would vouch for the other's still-stale values. - A null owner (auth bootstrapping, or signed out) still trusts the cache, which keeps the warm start and the login screen's last look. - An unstamped cache is not trusted once an account is known, so existing users take a one-time appearance reset on first load rather than a chance of seeing someone else's settings. - When a foreign cache is detected the values are dropped and the empty cache is handed to the new account, so a later single save cannot re-trust the rest of the previous account's state. Owner is the user id because /settings is user-scoped server side; it lives in one helper (`appearanceCacheOwner`) so it can be widened if appearance moves to profile scope. `shouldLoadApiTheme` is gone: it had become a synonym for `appearanceCacheOwner(...) !== null` with no callers left. Part of #376 AI-use disclosure: implemented with Claude Code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): make the settings contract enforceable and fix the appearance cache The contract manifest landed as a document nothing checked. This makes it a mechanism, and fixes the one defect in the change set that hurt users on merge rather than at cutover. Web appearance cache. useTheme cleared the cache for any account whose stamp did not match and never repopulated it — the only writers were the four user-action setters — so every upgrading user lost their warm start on every load, not once, and x-large-text and high-contrast users lost theirs too. The owner-stamp protocol is replaced with per-account key namespacing (`silo-theme:7`): a foreign value is absent rather than present-and-distrusted, so nothing has to be deleted, the first account keeps its warm start, and there is no shared stamp for a second tab, a stale debounce timer, or an out-of-order effect to race on. Widening ownership to profile scope, which this manifest requires, is now a change to appearanceCacheOwner alone. Adds the API-to-cache mirror useTheme was missing, cancels pending debounced writes across an account change, and re-seeds provider state during render so no frame paints the previous account's look. Canonicalization. writeCanonical used json.Marshal, which HTML-escapes < > and &, and canonicalNumber used Go's 'g' format — both diverge from RFC 8785, so the first label containing an ampersand or bound below 1e-4 would have forked the server's ETag from every conforming client. Output is now byte-identical to ECMAScript String() across the edge cases, verified against node. The ETag also covers the value schemas, which decide what the server accepts and previously could change while the tag stood still. All four derived representations are memoized; a conditional GET no longer costs a full parse and re-serialize. Validation. strictUnmarshal's decoder.More() answered false for a stray ] or }, so `true]` validated as a boolean. Enum matching compared fmt.Sprintf tokens, so the string "3" satisfied an integer member. Declared steps were never enforced. The language pattern rejected tags both mobile platforms emit unprompted (en_US, ca-ES-valencia, ar-EG-u-nu-latn) and never normalized case, so en-US and en-us were two rows for one preference; NormalizeValue now canonicalizes on the shared path. Manifest. show_forced_subtitles defaulted false where the server column is NOT NULL DEFAULT true, which would have turned forced subtitles off for every profile that never touched it. preferred_quality declared 13 members where the planner speaks 6 and collapses the rest to auto. metadata_language's allowlist was bound to the very column it migrates from. subtitle-appearance pinned fontFamily to three families while Apple stores any installed system font. Registers five user-facing settings the clients already ship, and corrects three notes that described Android behaviour that was not true. Enforcement. The package had no non-test callers, so MustLoad never ran; it now loads and logs at startup. The inventory test compared the manifest against a hand-copied map and could not see the drift it named; it now iterates settingsRegistry and checks defaults too — both verified to fail on injected drift. Adds .github/workflows/ci.yml, the repo's first CI that runs go test, go vet, gofmt, and the frontend suite. Known pre-existing failures are named individually in the Makefile so everything else stays gated and the list can only shrink. Part of #135 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): align the sleep timer default and range with the shipped client Android is the only client that implements this setting. It clamps to 0..240 and defaults to 30. The manifest said 0..480 with a default of 0, so a manifest-driven UI would have offered durations no client can store, and every user who never opened the picker would have had the preset silently turned off at cutover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: give the new workflow the deps it actually needs The first run exposed two gaps in the workflow itself. go build ./... fails without libvips headers, because h2non/bimg binds libvips through cgo and pkg-config; the Dockerfile installs the same package. And pnpm/action-setup resolves its version from package.json, but there is no package.json at the repo root — the packageManager field lives in web/package.json, and a job's defaults.run.working-directory does not apply to an action's inputs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(web): stop the diagnostics download test depending on the Node version new Response(blob) reads the body through blob.stream(), which jsdom's Blob does not implement on Node 22 — the version the Dockerfile builds with. The test passed locally on Node 24 and threw "object.stream is not a function" in CI. Nothing in it asserts on the body, only that the object URL and filename reach the anchor, so a string body is equivalent and works on both. Surfaced by the CI workflow added in this branch, which is the first thing in this repo to run the frontend suite anywhere but a developer's machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): copy the settings contract into the container build context Both Dockerfiles copy cmd/, internal/, migrations/ and web/embed.go, but the manifest lives in contracts/settings/v1 — an embedded Go package that sits outside internal/ because clients vendor those files. The image build therefore fails with "no required module provides package .../contracts/settings/v1". Caught deploying to the dev box. Nothing had built an image since the manifest landed: the Docker workflow only runs on pushes to main and workflow_dispatch, and CI's go build runs against a full checkout, so neither gate covers the container context. This would have broken the published image on merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): enforce the language-tag and step constraints the manifest declares A sweep of all 43 manifest definitions against the running server (160 checks: declared default, both boundaries, and deliberate violations for each remote key) found two places where the live registry accepts what the contract forbids. Both are fixed by calling the contract's own validators rather than adding a second implementation. playback.audio_language was checked as "32 characters or fewer", so the server stored "!!!" for a field the manifest declares as language_tag — a value track matching would then silently never match. It now requires a well-formed tag via settingscontract.NormalizeLanguageTag. The empty string is still accepted: the string-only endpoint has no way to send null, and both Android and web send "" to clear the choice, so rejecting it would break clearing the preference. player.playback_speed declared step 0.05 and nothing enforced it, so 0.26 was stored — a value no client's stepper can represent and that every client would silently snap on the next write. settingscontract.StepAligned is now exported and used by both the contract validator and the registry, so there is one definition of "on step" rather than two that can drift. This gives the contract its first production consumer beyond the startup load, which is the direction Phase 2 continues in. Also fixes a genuinely flaky test that the new CI gate would have hit intermittently: TestRemoveJellyfinCompatWebDisablesWebSetting used t.TempDir as the install root, but the endpoint returns 202 and its goroutine keeps writing there after the test body returns, so cleanup tripped "directory not empty" roughly one run in four. Confirmed pre-existing and unrelated to settings; the suite now passes six consecutive full-package runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): keep widened numeric bounds resolvable at older revisions A bound was one scalar plus the revision that introduced it, which discards the value it replaced. Widening a maximum from 240 to 480 at revision 3 left a revision-3 client with no correct answer against a revision-1 server: honoring 480 offers values that server rejects, and filtering the tagged bound out leaves the setting unbounded. Since clients are specified to filter their pinned contract against the server's advertised revision, the bound has to carry what it used to be. Bounds now hold their full history, oldest first, and AtRevision hands back the limit a given peer actually enforces. A bound nobody has widened still serializes as a bare number, so the manifest reads the same and untouched entries do not churn the ETag. Validation gains the rules the representation makes checkable: a maximum may only grow and a minimum may only shrink, history is strictly ordered, later entries must say when they arrived, and the first entry cannot predate the definition. That last rule is the lower bound allowed_scopes already enforced; the same gap is closed for enum members, which could previously claim to predate the definition containing them. Reported by Codex review on #479. * fix(settings): accept the partial subtitle appearance objects already stored The schema required all nine properties, but the current API accepts and round-trips sparse objects — settings_device_test.go stores {"fontSize":"xxlarge"} and reads it back — and the web client has always merged whatever it gets over DEFAULT_SUBTITLE_APPEARANCE. Requiring the full object would have made the cutover migration quarantine preferences users really set, or block on them. Every property is now optional and a stored value is documented as a sparse override merged over the definition's complete default. An empty object is still rejected: an override that overrides nothing is the same state as no override, which the contract represents as unset. Cross-scope resolution is deliberately unchanged. A device override still replaces the profile's object rather than merging into it, because a device override means "draw subtitles this way on this screen", not "amend the profile" — and that is what the server does today. Reported by Codex review on #479. * fix(jellycompat): scan the parent directory when a sidecar changes Autoscan matched scantrigger rejections by comparing RequestError.Message against literal strings. One of those messages became "Unsupported media file extension for library type" and the copy in handlers_autoscan.go did not, so the comparison silently stopped matching. The effect is user-visible: a Jellyfin client posting a change for Movie.nfo or poster.jpg gets a 400 and the batch is abandoned, when the sidecar should have resolved to a scan of the directory containing it. Three tests covered exactly this and had been excluded rather than read. RequestError now carries a Reason the caller can switch on. Message stays prose for the client reading the response — it is meant to be reworded, and nothing should break when it is. Also makes two tests honest about asynchronous work. The Jellyfin Web teardown deleted its install root while the operation goroutine was still writing to it, where a late write recreates a path RemoveAll already walked past; it now waits for the operation's terminal state, which required exporting CurrentWebOperation. And the direct-play If-Range test pinned size and mtime so ctime was the only remaining validator, then read it back inside a single coarse-clock tick — it failed about 85% of the time on main for a reason unrelated to what it tests, and now rewrites until the stamp moves. With those fixed, GOTEST_KNOWN_FAILURES is empty and gone: make test-go runs the whole Go suite. The one test that cannot pass yet — TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion, which has failed since the commit that introduced it and describes unimplemented v3 planner behavior — carries a t.Skip explaining that where the test is, rather than a regex in the Makefile. Reported by CodeRabbit review on #479. * fix(settings): reject JSON the decoder would otherwise rewrite Two cases where encoding/json accepts input by quietly changing it, which is the one thing a contract promising byte-identical agreement between peers cannot tolerate. Duplicate object properties. jsonschema.UnmarshalJSON keeps the last occurrence, so {"fontSize":"small","fontSize":"large"} validated and stored "large". Which one wins is a property of the parser, not of the contract: a client generated against a different JSON library can disagree about what it just sent, and the canonical form cannot represent the duplicate at all. Lone surrogates. An unpaired \ud800 became U+FFFD and canonicalization reported success, so the server would issue canonical bytes and an ETag for an artifact a conforming implementation must refuse — RFC 8785 requires terminating here. Substitution also means the value read back is not the value written. Both checks run before the decode that would hide them, on the shared decodeJSON path that the manifest, its public projection and every value schema go through, and again on the object branch of ValidateValue, which uses a different decoder. Reported by Codex review on #479. * ci: gate Go lint on the lines a branch changes AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go job ran only gofmt and vet. A change failing the documented Go lint gate passed all three jobs. Running the linter as-is is not an option: the tree has ~296 findings today, which is why this half of `make lint` was never enforced. Blocking every PR on a cleanup nobody has scheduled gets the gate deleted again, so CI runs with --new-from-merge-base and only the lines a branch touches have to be clean. The count can then only fall. golangci-lint is built from source at a pinned version rather than downloaded. A released binary refuses to run against a Go newer than the one it was built with, and go.mod here tracks Go closely enough that the current release already fails that way on 1.26.4. .golangci.yml declared version 2 while still using v1's issues.exclude-rules key. Current golangci-lint ignores it, so the "allow repeated strings and unchecked cleanup errors in tests" exclusions silently did not apply — 16 findings in test files that the config says to skip. Moved to linters.exclusions, which `golangci-lint config verify` accepts. The four lines this surfaced in scantrigger are fixed rather than excluded: its repeated status codes and messages are now named constants, so one condition cannot end up worded two ways. Also drops the workflow token to contents:read and stops persisting credentials in the three checkouts, neither of which any job needs. Reported by CodeRabbit and Codex review on #479. * docs(v1): record the settings removal as a pre-lock exception The design removes the legacy /api/v1/settings routes and the profile DTO preference fields, while AGENTS.md states /api/v1 is additive-only and removals go through Deprecation/Sunset. Read together those contradict. They do not actually conflict: v1-scope.md scopes the additive-only rule to "when the scope locks", and the scope is still open, so a removal taken now is in scope and there is no amendment process to invoke yet. But that reasoning lived only in the settings design, where nobody checking the API policy would find it. v1-scope.md now carries a pre-lock removals table naming what goes and why waiting is worse, and states the deadline the argument depends on: a removal listed there must ship before lock or fall back to Deprecation/Sunset. AGENTS.md points at the table and says to treat an unlisted removal as a mistake. Reported by CodeRabbit review on #479. * fix(settings): clear the remaining review findings Small, unrelated except that each was raised on #479. compileObjectSchemas parsed every non-directory file under schemas/ as a JSON Schema, so a stray editor backup or .DS_Store would panic the server at startup through MustLoad. schema_ref can only name a .json file; anything else is skipped. cmd/silo used MustLoad while the ETag check beside it and every other startup failure use log.Fatalf. It now fails the same way, so a bad contract prints an error instead of a stack trace. TestRegistryDefaultsMatchTheContract called scalarDefault before handling null, and scalarDefault rejects null as non-scalar — so the subtest skipped and the comparison after it was unreachable. A nullable contract default could disagree with a non-empty registry default and nothing failed. Confirmed by injecting that drift, which now reports it. The three appearance providers each adapted the auth context to AppearanceAuth with identical code, putting the shape of auth back in three places that widening cache ownership would have to find. useAppearanceCacheOwner now does it once. useTheme.test.ts cleared storage.KEYS between cases, but appearanceCache writes namespaced keys and an owner pointer that are not in that list, so both survived and the suite was order-dependent. It clears the store, as storage.test.ts already did. The abs_smart_collection_store comment is reworded rather than given back its SQL quotes: gofmt folds a pair of apostrophes in a doc comment into a typographic quote, which is how it became one in the first place. Reported by CodeRabbit review on #479. * feat(settings): add canonical typed storage for the settings contract The cross-platform settings contract needs one typed store behind it before a resolver, routes or a migration can exist. This adds that storage to both user-store backends and holds them to identical behavior. PostgreSQL gets user_setting_values with the scope CHECK constraints, the five partial unique indexes that enforce one explicit value per identity, and the covering indexes the one-query read path needs, plus user_setting_mutations for mutation_id idempotency and the inert user_setting_migration_rejects audit table. The per-user SQLite store gets the same shape minus user_id, since that database is already user-scoped. The UserStore interface grows the typed operations: read one explicit value at one scope, collect every candidate row for a resolution request in a single query, upsert with a revision increment, unset, and the idempotency receipt operations. The resolution read deliberately returns unranked candidates so the resolver can rank in Go — one query per request, never one per scope, which the pgx query-count test pins. Delete behavior is application-enforced. Neither backend can inherit it from constraints: the SQLite store declares no foreign keys, and library, series and device columns are not FK targets in Postgres either. Profile deletion cascades to profile-anchored values while account scope survives, forgetting a device clears its profile_device values alongside the legacy overrides, and the library/series purges remove only what is scoped to that entity. The shared conformance suite covers all of it, including the set-versus-unset distinction for false, 0, "" and null, so a divergence between the two backends fails a test rather than reaching a client. Part of #376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(settings): pin the settings-value schema constraints in both backends Completes the storage track. The conformance suite exercises the store API, which validates identities in Go before any SQL runs — so nothing noticed whether the CHECK constraints and partial unique indexes actually existed. The one-time migration writes these rows in bulk without going through the per-request path, so the schema is the only thing guarding it. Adds constraint tests to both backends covering every scope's column requirements, rejection of an unknown scope, a profile that does not exist, non-JSON values, and each of the five partial unique indexes. Also clears the lint the storage commit did not get to: sql.ErrNoRows and pgx.ErrNoRows compared with == rather than errors.Is (which fails on a wrapped error), an unchecked rows.Close, and repeated fixture literals in the shared suite now named so a backend that confuses two scope columns fails on the assertion rather than on a typo. * fix(settings): close the review findings in the validator and the theme cache Four defects the existing tests did not reach. The web theme resolver compared the server's value against the appearance cache and fell back when they agreed, but the mirroring effect writes the server's value into that same cache — so the comparison held on the first render and stopped holding on the second, reverting an explicitly chosen theme to the default. The server's value is this account's own stored choice, so it now simply wins. The regression test re-renders rather than asserting on the first paint, which is why the original one passed. golangci-lint's exclusions.paths is a path regex, not a directory list, so a bare `web` also excluded internal/jellycompat/web_component.go, internal/webhooksync/, internal/notifications/webhook*.go and eleven other non-test files that were being linted before. Anchored. json.Number is a string kind, so `"1.5"` unmarshalled into it happily and Float64 parsed the quoted digits: a numeric setting validated as a JSON string and NormalizeValue stored the quoted form into jsonb. Rejected. The lone-surrogate check ran only on the object branch, so a lone surrogate in ui.custom_css decoded to U+FFFD on SQLite and was refused outright by Postgres jsonb — the two backends disagreeing about whether the same value could be stored. Hoisted to cover every type. The strict language-tag validation this branch added is correct, but it rejects what the shipped Android client sends; the companion fix is silo-android 4aeb78b4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(auth): stop TestJWT_TamperedToken passing a valid signature The test overwrote the last character of the signature with "X". An HMAC-SHA256 signature is 32 bytes, so its base64url encoding is 43 characters and the final one carries only four significant bits — U, V, W and X all decode to the same trailing byte. Roughly one token in sixteen was therefore left byte-identical and validly signed, and the test failed because ValidateToken correctly accepted it. Measured at 3098/50000 (6.2%) over distinct signatures; it just failed the Go job on this branch for reasons unrelated to the branch. Flipping a character in the middle of the signature is 0/50000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): reject raw invalid UTF-8, not just escaped surrogates The previous commit hoisted the lone-surrogate check to cover every value type, but that only closes the escaped path. A raw 0xff byte inside a quoted string — what an HTTP body carries when a client encodes text in the wrong charset — is not an escape, so the surrogate scan never sees it, while encoding/json still substitutes U+FFFD and reports success. NormalizeValue then stores the original bytes, which SQLite's json_valid accepts and Postgres jsonb refuses: the same backend divergence, reached the other way. Found by the Codex review bot on the previous commit's own diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(settings): size the library page state bound to what the web client writes ui.library_page_state's `search` was bounded at 256 characters. The web client serializes an advanced library view as URLSearchParams, encoding each filter rule as three groups[i][rules][j][field|op|value] keys — measured at 216 characters for one rule, 518 for three, 820 for five. The current endpoint validates this key by checking only that it parses, so those oversized values are already stored in production. Typing them at the declared bound would have failed the migration for anyone who had saved a view with more than one filter rule, and rejected the equivalent write afterwards. Raised to 4096, which clears ten rules with room to spare while staying a real bound. The test pins it against the key shapes libraryPageSearchParams.ts actually emits rather than a round number. Reported by the Codex review bot; the lengths above were measured by calling serializeLibraryPageSearchParams, not estimated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): split quality into two axes and register the orphan keys Two manifest changes the cutover needs. **Quality becomes resolution + bitrate.** The legacy ladder values (1080p-high, 720p-medium, 1080p-8, 420p, 328p) were never a third dimension — they are a bitrate spelled into the resolution string. The web player already decomposes them: useTranscodeQuality.ts defines 1080p-high as {resolution: 1080p, bitrate: 10000} and sends the two separately, so the compound form never reached the wire. Downloads went further and kept only a bitrate ladder. So playback.preferred_quality keeps the six clean resolutions and playback.max_bitrate_kbps becomes the second axis, nullable because "uncapped" is a real answer and a numeric sentinel would need widening every time hardware improves. Clients compose their own presets from the pair, which means retuning what "High" means is a client release rather than a contract break. Migration decomposes each legacy value losslessly, so none of them lands in the rejects table. **The five extension-bag keys are now definitions.** card_overlays, next_up_mode, sidebar_pins, disabled_library_ids and library_order reached the server only through the unknown-key path, stored as unvalidated strings. Two of them the server reads back — next_up_mode decides home section assembly and card_overlays falls back to an admin default — so they cannot be demoted to client-local. Registering them is what lets the extension bag close. Adds three schemas for their shapes and a test that exercises every schema_ref against a real value: each of these is nullable with a null default, so the existing default-validation test returns at the null branch without ever compiling the reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical resolution engine One answer to "what is this setting, for this profile, on this device, for this content". Before this, each caller carried its own ladder: catalog/detail.go resolved subtitles across four levels by hand and audio across three, handlers/settings.go had a two-level device/user resolution with a lazy write-back inside a GET, and jellycompat read profile columns directly. Those disagreed about precedence, which is the drift the contract exists to remove. Resolution is one batched read regardless of how many keys, libraries, or series are in play — ranking happens in Go against each definition's declared resolution_order. Five sequential index lookups per key per item is the implementation the design rejects, and a season view is exactly where it would have shown up. An absent identity drops its scope rather than erroring, so one code path serves an identified client, an anonymous jellycompat seed, and a batch spanning many series. Rows for a foreign profile, device, library or series are ignored even though the batched read returns them. Constraints narrow without destroying: a capped 4K preference resolves to the cap, reports itself constrained, and keeps the authored value so it takes effect the day the cap lifts. Two cases needed care — null on a nullable numeric means unbounded, so a ceiling must cap it rather than rank it equal and let the value that most needs capping slip past; and an allowlist falls back to a permitted member rather than the definition's default, which may itself be outside the list. Adds ValueSchema.CompareValues to the contract package, since ordering values is what makes a ceiling or floor mean anything and value semantics belong with the schema that declares them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the one-time migration planner The conversion rules from legacy settings storage to canonical values, as ordinary Go rather than twice in two SQL dialects. Both backends read their own rows, hand them to Plan, and write what comes back — so the decisions are testable without a database and SQLite and Postgres cannot drift apart in what they decide. The rules that needed care, each pinned by a test: Column defaults are not choices. quality_preference is NOT NULL DEFAULT '1080p' while the contract defaults to auto, so migrating the column unconditionally would pin every profile in the install to 1080p having never chosen it — and that stored value would then outrank the contract default forever. Same for language 'en', subtitle_mode 'auto', and show_forced_subtitles true. The empty string is unset, not a value. The legacy string API had no way to send null, so both Android and web spell "clear my choice" as "". Storing that would make a cleared setting outrank the default. Legacy quality decomposes rather than rejects. Every compound value maps to a resolution and a bitrate from the ladder in useTranscodeQuality.ts, so nothing lands in the rejects table. Account rows fan out to every profile, which is the account-to-profile move the contract makes for appearance and search scope: a household that shared one theme each end up owning theirs. Legacy strings become typed JSON — "true" to true, "30" to 30 — or every generated binding would fail to decode what the migration wrote. Nullability differs per backend, so profile columns arrive as pointers and the caller resolves "chose the default" versus "never written" when it reads. jellycompat's DisplayPreferences blobs ride the same table under synthetic keys and are left alone; they are that subsystem's storage. Everything that cannot convert is recorded with a reason rather than dropped, and a final test asserts every planned row would be accepted by the mutation endpoint's own validation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): run the one-time migration on the SQLite backend Wires the planner to real storage as userdb migration V15. V14 created the tables; this fills them. It runs inside runMigrations' existing transaction, so a database either comes out fully migrated or untouched — a partial migration is the one state neither the operator's backup nor a rollback covers. Pinned by a test that rolls back and asserts nothing was left behind. Two things the wiring had to get right that the planner could not see: Reject identities are JSON. Postgres declares that column jsonb NOT NULL and SQLite guards it with a json_valid CHECK, so the free-form "profile=p1 device=d1" the planner emitted would have failed to insert — on exactly the rows the table exists to record. They are structured documents now, which is also queryable. Subtitle and audio preferences are two tables keyed the same way, so they merge into one per-series record before planning. Converting them independently would have produced two rows racing for the same identity. Every legacy read tolerates a missing table, since this runs against databases created at any schema version, and preferred_metadata_language is deliberately absent: that column exists only in the Postgres schema. Tested end to end against a real database rather than only through the planner — the rows land, satisfy the scope CHECK and the partial unique indexes, and hold valid JSON. Also covers the empty-install case and asserts a second run fails rather than silently doubling every value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): run the one-time migration on the Postgres backend The mirror of userdb V15, registered with goose as a Go migration rather than SQL: the conversion validates every value against its own definition and re-encodes it as typed JSON, and one legacy quality string becomes two rows — neither is expressible in SQL without duplicating the manifest. The rules stay in internal/settingsmigrate, so the two backends cannot disagree. RunTx, so the whole backfill lands in goose's transaction. The down migration empties the canonical tables; the legacy ones are never touched by the up, which is what keeps the cutover reversible until the follow-up migration drops the superseded columns. preferred_metadata_language is read here and only here — the column exists in this schema and not in SQLite's, so this is the sole source for catalog.metadata_language. Verified against a real Postgres: the full goose chain runs, 1080p-high decomposes to ("1080p", 10000), values land as typed jsonb rather than strings (jsonb_typeof reports number), rejects carry a queryable jsonb identity, and the composite profile foreign key refuses a row naming a profile that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): add the canonical settings API The routes that make the typed storage reachable. Until now the manifest, the resolver and the migration all existed with nothing able to call them. GET /settings/contract serves the public manifest behind an ETag — clients vendor a pinned copy and generate bindings from it, so the common request asks "still the same contract?" rather than transferring it. Its capabilities sibling reports revision and supported scopes for feature detection instead of version sniffing. /settings/values/{key} reads, writes and clears an explicit value at one named scope, which is what a reset affordance needs: "did I set this here" is a different question from "what applies", and the old endpoint could only answer a blurred version of both. Scope comes from the query while profile and device come from session headers, so one profile cannot address another's settings by naming it. /settings/values/effective resolves any number of keys in one request, with the resolution ladder and the source of each answer reported so a client can offer "reset this device's override" against the exact row holding it. Asking for no keys returns every remote setting, which is what a settings screen wants. Writes are idempotent when a client sends X-Silo-Mutation-Id: a retry after a dropped response replays the receipt, and reusing an id with different content is a conflict rather than a silent overwrite of the wrong thing. Three things the string-only endpoint could not do, each pinned by a test: an unknown key is refused rather than stored in the extension bag, values are checked against their declared type and range, and a write to a scope the definition does not allow is rejected. Registered before the catch-all /{key} routes, which would otherwise swallow "contract" and "values" as setting names. The legacy endpoints stay live for now; deleting them is the next commit, once their consumers move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): generate typed bindings for all four languages One generator rather than one per repo. The point of the contract is that four codebases agree on keys, types, scopes and defaults, and four independently written generators would be four chances to disagree. Go and TypeScript land in this repo; Kotlin and Swift are written into the sibling client checkouts, skipped with a note when they are not present so a server-only developer can still run it. Output is sorted by key so an unrelated manifest edit does not produce spurious diffs. The Kotlin output is the interesting one: it generates the DeviceSettings allowlist Android maintained by hand, plus the BOOLEAN_KEYS/INT_KEYS/ DOUBLE_KEYS classification it kept as a *second* hand-maintained table that had to agree with the first. Both are manifest questions now, so the whole class of "wrote a local key to the server" and "flushed a value the store could not parse" bugs stops being possible by construction. The TypeScript output carries the full definition table — labels, controls, enum members, bounds — so web/src/lib/settingsManifest.ts can be deleted rather than kept in sync: it declared 17 definitions against the contract's 49, with its own two-scope model that does not match the contract's five. make verify-settings-bindings fails when the committed output disagrees with the manifest, wired into CI, so a manifest change cannot merge leaving every client reading stale keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): add the two-axis quality picker and typed settings hooks Quality becomes one picker over two stored values. The server holds a resolution cap and a bandwidth cap independently, which is what the player has always sent on the wire — useTranscodeQuality.ts has decomposed 1080p-high into {resolution, bitrate} for as long as it has existed. Presets live in the client rather than the contract so retuning what "High" means is a one-line edit here instead of a contract change four codebases have to agree on, and an older server keeps working because it only ever sees the two axes it already understands. A combination no preset covers still gets a truthful label rather than a picker showing the wrong entry: reachable by setting the axes separately through the API, or from a legacy value whose bitrate is off this ladder. Choosing an uncapped preset clears the bitrate rather than storing a sentinel, so "no cap" stays the absence of a value at every layer. Adds hooks over the canonical API alongside the legacy ones rather than replacing them wholesale — a key that is not in the manifest cannot be expressed, because SettingKey is generated from it, and the default for an unset value comes from the generated table rather than a literal at the call site. That last part is what stops the flip-off bug the Apple client carries a hand-written guard for. A test asserts every preset composes values the contract actually accepts, so a preset naming a resolution outside the enum or a bitrate outside the declared bounds fails here rather than 400ing when a user picks it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): resolve catalog playback preferences through the contract catalog/detail.go held the two hardest ladders in the codebase: subtitles resolved across four levels by hand, audio across three, each partially overriding the last through Has* flags. Both now call the canonical resolver, so the precedence lives in the manifest and this file cannot disagree with the contract about which override wins. Adding a scope is a manifest change rather than another branch here. The subtitle track signature stays on its specialized table — it identifies a concrete track rather than expressing a preference, so it is not a setting. Resolution keeps the memoization the old lookups had: the audio resolver still reads once per profile and once per library rather than once per file, which is what kept a many-track audiobook detail page fast. The test that guards it now counts resolver reads instead of GetProfile calls, since the guarantee is about scaling with file count rather than about which method does the reading. Four tests seeded the profile column directly. That column is a migration source now, not a read path, so they seed the canonical value instead — they were passing against storage nothing reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): close the unknown-key extension bag keyUsesUserScope returned true for any key the registry did not know, so a client could invent a production setting unilaterally and the server stored it as an unvalidated string. That is how six ui.* settings and five orphan keys reached production untyped, and it is the root enabler the design names. An unknown key is no longer a user setting, so the legacy write path rejects it and the canonical API — which validates every value against its own definition — is the only way to store something new. jellycompat's DisplayPreferences blobs ride the same table under synthetic keys and keep working: they are that subsystem's storage rather than user settings, and they move to dedicated storage in the follow-up rather than being dropped here. Also repoints the DisplayPreferences seed at the canonical resolver. Resolved at profile scope with no device on purpose — Jellyfin clients do not carry Silo's device identity, so a device override leaking into the seed would hand one device's settings to every Jellyfin client on the account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): enforce viewer quality caps through resolver constraints constraintsFor was the unwired half of the preferences-versus-restrictions seam: it returned nil, so a profile capped at 1080p by policy still resolved its stored 2160p preference at face value through the effective endpoint. The settings routes are mounted inside RequireViewerAccess, so the resolved access scope is already on the request context. Scope.MaxPlaybackQuality holds a literal member of the contract's quality enum ("1080p"/"2160p"), which is exactly what the manifest binds playback.preferred_quality's ceiling to under policy_input "max_playback_quality" — so the wiring is a direct map with no translation table. An empty value means the policy sets no cap, expressed by returning nil so the resolver leaves the preference alone. catalog.metadata_language deliberately stays unconstrained: the manifest notes record that the allowlist draft was circular (the policy input it would bind to is populated from the very preference it would narrow). The handler test covers both halves of the seam: a 2160p preference under a 1080p cap resolves to the cap with constrained:true/ceiling and the authored value reported in stored_value, the stored row itself is not rewritten, and an uncapped viewer gets the preference unchanged with no constraint noise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): publish user_settings change events Add a user_settings realtime channel so clients learn when a setting changed on another device without polling. The channel is modeled on user_state: non-admin subscribable, per-user addressed envelopes, null snapshot. SettingValuesHandler gains an EventsHub and publishes user_settings.changed after every successful PUT and DELETE on /settings/values/{key}. The payload carries only key, scope and profile_id — never the value. Admins receive every user's user-scoped events, so a value in the payload would leak private settings to admins; interested clients re-fetch over the scoped REST API instead. The payload is always non-empty because an empty Data falls back to a null snapshot in the hub. A nil hub (tests) skips publishing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): sweep expired mutation receipts daily Setting-mutation idempotency receipts were written with an expires_at that nothing enforced, so the table grew forever. Add a hidden daily system task (05:00) that walks every login account, opens its user store, and calls DeleteExpiredSettingMutations. A user whose store fails to open or sweep is logged and skipped so one broken store cannot stall retention for everyone else; the delete is idempotent, so the next run repairs anything missed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(settings): resolve metadata language canonically in access and policy Repoint the last legacy column readers onto canonical contract resolution (settings cutover task A4a): - access.Resolver and policy.ViewerResolver now resolve catalog.metadata_language through settingsresolve (profile scope -> contract default) via a shared access.PreferredMetadataLanguage helper, instead of reading user_profiles.preferred_metadata_language. Resolution is deliberately unconstrained: the policy input this preference feeds is the one a constraint would have to reference, which is circular — see the key's manifest notes. - playback start now resolves playback.audio_language canonically for the profile default instead of reading user_profiles.language, matching the catalog detail path. Series and library override handling is unchanged. - items.go needed no change: it already consumes the resolver-produced scope.PreferredMetadataLanguage. The legacy columns keep their values but are no longer read on these paths; a profile with only a column value now resolves to the contract default, and a stored canonical value wins. Tests pin both directions in access, policy (including scope parity, where the column is now a decoy), and the playback handler. Read cost is one batched store read per resolution, same as the profile-row read it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(jellycompat): give DisplayPreferences its own table The Jellyfin DisplayPreferences blobs rode the legacy user_settings key/value table under synthetic jellycompat:* keys, which forced the legacy settings API to carry a prefix carve-out in its otherwise-closed unknown-key gate. They are the compat subsystem's storage, not user settings: the contract neither validates nor resolves them. Move them to a dedicated jellycompat_displayprefs table in both backends, keyed by (prefs id, client) per user, with the blob stored as opaque text served back byte-for-byte (deliberately not jsonb, which would re-serialize it). The data-copy migrations — per-user SQLite V16 and a paired SQL + Go goose migration for Postgres — are transactional and harmless to re-run, and both drive their key parsing and row classification from the new internal/jellycompat/displayprefs package so the backends cannot diverge, following the internal/settingsmigrate precedent. A jellycompat:* row that does not parse as a DisplayPrefs key (only ever writable through the removed carve-out) is recorded in user_setting_migration_rejects rather than silently deleted. With the last non-settings tenant gone, the jellycompatSettingPrefix carve-out is deleted: the legacy settings endpoints now refuse jellycompat:* keys like any other unknown key and never surface them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): serve admin user-settings through the canonical API Replace the ten string-registry /admin/users/{id}/settings* and device-settings* routes with the canonical contract surface: one list of every explicit value the target user has stored across all scopes, and set/delete at an explicit scope named in the query string. The admin handlers live on SettingValuesHandler and share the session routes' implementation rather than duplicating it — the same key/scope parsing, identity validation, contract scope allowance, value normalization and mutation-receipt idempotency, factored into keyedScopeFromRequest/completeIdentity and setValueAt/deleteValueAt. The only admin-specific parts are the target user coming from the path, profile and device ids coming from the query (an admin holds no session claim to the user being inspected, so its named profile is checked to exist), and change events attributed to the target user so their clients refresh. The list is a new UserStore read, ListAllSettingValues, implemented in both backends and pinned by the shared storetest conformance suite: the admin surface wants the stored truth (which overrides exist, for a per-row reset affordance), which no resolution-shaped read answers. The ten removed routes are recorded in the pre-lock removals table in docs/architecture/v1-scope.md per the v1 API rules; the web admin device-overrides page moves onto the new surface in the Phase B rewrite inside this same unmerged PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(settings): add the cross-platform conformance fixture and its Go and web runners contracts/settings/v1/conformance.json is the spec's named drift gate: 21 hand-authored cases of {keys, stored rows, context, constraints, expected effective value + source}, every one executable against the shipped manifest. They pin the semantics most likely to drift across four resolver implementations: the full resolution ladder (series > library > device > profile > default), an absent identity dropping its scopes, foreign-identity rows never resolving, ceiling caps that report the authored value with constrained:true, the ordered-enum sentinels (auto below every cap, original above), null-on-a-nullable-numeric meaning unbounded and being brought down by a ceiling but ignored by a floor, allowlist falling back to the first allowed member rather than the (possibly forbidden) default, and playback.subtitle_appearance resolving device > profile only with the sparse device object replacing, not merging. Cases may inject a constraint binding onto a copy of a real definition so constraint kinds no shipped definition carries stay testable. The Go runner (internal/settingsresolve/conformance_test.go) resolves each case through the real resolver against the embedded manifest. The web runner (web/src/lib/settingsConformance.test.ts) runs the same cases through a new client-side resolver, web/src/lib/settingsResolve.ts, which mirrors the server's semantics; the TypeScript bindings now carry each definition's ordered flag and constrained_by binding so that resolver derives constraint behavior from the contract instead of hardcoding it. Both runners reject unknown fixture fields — schema drift in the fixture itself is drift — and both refuse a fixture authored against a different manifest revision. The fixture travels with the bindings: make settings-bindings vendors the copy the web runner reads, and make verify-settings-bindings fails CI when that copy goes stale. The Kotlin and Swift copies land together with their runners in the client repos, which will pick their own test-resource paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): review pass over the phase A stack Fixes the eight adversarially-confirmed defects the review of the unpushed phase A stack (40e0f77a..1f2c7fe4) found, each with a test that fails without its fix. Writers left behind by the language cutover (high).22e9d7f1made access, policy and playback start resolve catalog.metadata_language and playback.audio_language exclusively from user_setting_values, but POST/PUT /profiles — the write path the shipped web UI uses — still wrote only the legacy columns, so a language change after the one-time backfill never took effect (a stale backfilled row, or the contract default, won forever). Profile mutations now mirror their preference fields into the canonical profile-scope rows through the same contract validation /settings/values applies (audio, subtitle and metadata language, subtitle mode, forced subtitles; the empty string clears the row, matching the migration's unset spelling), publish user_settings.changed for each row moved, and 400 on a value the canonical endpoint would refuse. quality_preference is deliberately not mirrored: the server never resolves the legacy column and the two-axis picker already writes canonically. Web admin settings 404s (high + medium).facad78dremoved the ten /admin/users/{id}/settings* and device-settings* routes but shipped no web changes, so the user-detail settings and device-overrides tabs and the devices-page override editor were dead. The seven admin hooks now speak the canonical values API: one list across all scopes feeds both tabs, mutations address an explicit scope identity, values re-type through the generated contract (display stringifies for the registry-era controls), device rows are enriched with device and profile names client-side, and the removed bulk device reset becomes per-key deletes that treat 404 as already-reset. Silent metadata-language degrade (medium). PreferredMetadataLanguage now logs a warning with the profile and error when contract load or store resolution fails, so pool exhaustion is distinguishable from "no preference"; the healthy paths stay quiet. Displayprefs move data loss (medium). Under READ COMMITTED the blanket pattern DELETEs in moveDisplayPrefs/unmoveDisplayPrefs could destroy a row an old-binary instance committed between the SELECT and the DELETE during a rolling deploy — reproduced against real Postgres. Both directions now delete only the exact rows they read (rejects restore by primary key), leaving a late row stranded for a re-run to pick up. Coverage the review proved missing (medium x3): admin mutations are now tested to attribute change events to the target user, not the acting admin (the exact regression passed the whole suite before); the user_settings websocket channel is subscribed through the real events websocket, failing if the channel is dropped from either allowedChannelsForRole or AllChannels; and the conformance fixture gains three locked-constraint cases (replace, equal-value pass-through, locked default) so the Go and TypeScript locked branches — previously executable by no test on either platform — are pinned by the shared drift gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): read and write appearance and format preferences through the settings contract Move the four identity-sensitive preference hooks — useTheme, useCustomTheme, useDateTimeFormat, useSearchMediaScope — off the legacy string-only /settings endpoints and onto the canonical settings API. Each surface now reads through one batched useEffectiveSettings call and writes via useSetSettingValue at scope "profile", matching what the generated manifest declares: ui.theme / ui.text_scale / ui.text_weight / ui.high_contrast are profile-scoped with a profile_device override the effective read already resolves (no device-override UI exists, so writes stay profile-wide), and ui.custom_theme_vars / ui.custom_css / ui.date_format / ui.time_format / search.media_scope are profile-wide. Keys come from the generated SETTING_KEYS table, so a typo'd or unmanifested key can no longer be expressed. Because the canonical effective endpoint always answers — resolving unset keys to the contract default with source "default" — the hooks now use the source to distinguish "the profile chose this" from "nobody stored anything". That preserves the admin-default theme layering and keeps resolved-but-unchosen values out of the warm-start mirror. ui.theme moving account→profile scope means the appearance warm-start cache must not be shared by sibling profiles on one account, so appearanceCacheOwner widens its token from the user id to user id plus active profile id. Every cache read/write already resolves through that one function, so no call site could be left behind; the API→cache mirror, the render-time re-seed on identity change, and the debounced write cancellation all follow automatically. The ownership tests now cover profile switches within one account: no theme/text-scale/CSS leaks between profiles, each profile's warm start survives the switch, and a debounce armed by one profile never persists under its sibling. Part of the Phase B settings-contract cutover; the legacy hooks in queries/settings.ts keep their remaining callers until B4 deletes them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): store library, sidebar, and overlay preferences through the settings contract Phase B2 of the settings-contract cutover: the query-layer preference stores — sidebar pins, library page state, disabled libraries, library order, and card overlay prefs — move off the legacy string-valued /settings endpoints onto the canonical values API, using generated SETTING_KEYS and each definition's declared scope (profile for pins, visibility, order, and overlays; profile_device for page state and the remember toggle). Values are now written as typed JSON matching the contract schemas (sidebar-pins.json, library-page-state.json, library-id-list.json, card-overlays.json) instead of JSON-encoded strings, so the encoding the migration produced keeps validating. Every parser accepts both the canonical object value and the legacy string encoding, so nothing breaks while caches or older rows still hold strings. Semantics preserved deliberately: - Sidebar pin toggles keep their optimistic update with the revision-guarded rollback, now layered on the effective-settings cache entry (effectiveSettingsQueryKey is exported for exactly this). - The remember-library-pages toggle clears the device override to inherit again rather than storing the default, via useClearSettingValue; the canonical DELETE's 404 for "nothing stored" is treated as already-done, matching the legacy delete's idempotency. - Overlay prefs keep the admin default / kill-switch layering: the contract default null means "no preference expressed", which is what lets /settings/overlay-config defaults apply, and only a stored value overrides them. - Library visibility/order keep their optimistic local state with rollback on error; ids are normalized client-side with the same rules library-id-list.json enforces. parseDisabledLibraryIDs/parseLibraryOrder collapse into one parseLibraryIDList (they were byte-identical), and the serialize helpers disappear with the string encoding. Legacy hooks in queries/settings.ts stay for the remaining consumers until B4. Part of the settings-contract cutover (see docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): write playback, subtitle, and library preferences at canonical scopes The settings screens and the player panels were the last web surfaces still speaking the legacy string API, and each carried its own idea of where a preference lives. Playback and subtitle behavior wrote profile columns through PUT /profiles; auto-play and next-up wrote untyped strings; subtitle appearance went through three bespoke routes that existed only because the string API had no way to express an object-valued setting per device. All of them now read one batched effective resolution and write typed JSON at an explicit scope. Where each preference lands follows the manifest rather than the endpoint that happened to hold it: - Playback and subtitle defaults, and next-up mode, write at profile. - Subtitle appearance writes playback.subtitle_appearance at profile_device, replacing /settings/subtitle_appearance/effective and the PUT/DELETE pair on /settings/device/subtitle_appearance. One hook now owns that value for the settings screen, the in-player panel, and the cue renderer, which before each parsed the effective response separately. - Per-library edits write at profile_library with the library identity, one key at a time. The legacy endpoint replaced a composite row, so clearing one field meant re-sending the other three and losing any concurrent change to them; independent per-key writes have no such coupling, and "inherit" is a delete rather than a sentinel. - The in-player series choice splits along the line the contract draws: language and mode are preferences and move to profile_series, while the track index and signature stay on /subtitle-prefs because they identify a concrete track rather than expressing a preference. Controls render from the generated SETTING_DEFINITIONS. The hand-written registry beside it had drifted — it declared several profile-only keys as device overrides, and disagreed with the manifest about the bounds of two sliders — so the display helpers now derive control shape, options, bounds, and the device-overridable key list from the contract. (Deleting settingsManifest.ts itself is B4; nothing outside its own test imports it any more.) Two follow-on fixes fell out of reading the contract rather than the registry. playback.auto_skip_recap and playback.auto_play_next_preview are declared at profile_device but only the intro override was ever consulted, so a device override on either silently did nothing; the player resolves all three now. And per-library "Original Language" is gone: the contract types these as BCP 47 tags, and the phase-A migration already rejects "original" at profile_library, so offering it would have written a value the server refuses. Risk worth naming: LibrarySettings decides "overrides" from the resolved source rather than by comparing values, which is what keeps three distinct cases apart — a library row holding the same value as the profile is still an override, and a library row holding null is an explicit "no subtitles" rather than an absent choice. A screen that compared values would collapse the first into "inherits" and the second into "unset". Part of #135 AI-assisted: authored with Claude Code; reviewed and verified by the committer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): refresh settings from the user_settings channel A canonical settings write reached only the tab that made it. The server already publishes user_settings.changed on every write and delete, but no web client subscribed, so a preference changed on a phone or by an admin sat stale here until a manual reload or the 5-minute staleTime expired. Subscribe the channel and treat the frame purely as an invalidation signal. The payload carries the key, the scope and the profile — never a value, because admins receive other accounts' user-scoped events and a value there would leak private settings. Marking the value queries stale lets react-query refetch only what a mounted screen is reading, and a burst of writes coalesces into one fetch per key rather than one per event. A profile-addressed change to a profile other than the signed-in one is dropped: it cannot alter what this tab resolves. Account-scoped changes carry no profile and always invalidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): render settings from the generated contract web/src/lib/settingsManifest.ts was a hand-written table of labels, controls, defaults and bounds sitting beside the generated contract, and it had already drifted: it declared profile-scoped keys as device overrides, disagreed with the server on the type and range of several keys, and enumerated a language subset narrower than the one the player speaks. lib/settingsDisplay.ts has derived all of that from SETTING_DEFINITIONS since the contract landed, and nothing but the manifest's own test still imported it. Delete the manifest and its test. The one piece it owned that the contract cannot express is the language list — language settings are typed as BCP 47 rather than as an enum, so there is no member list to render — which moves to lib/languageOptions.ts and is now derived from the shared player language list. Two shapes ship: NAMED_LANGUAGE_OPTIONS for a control that spells its own unset entry, and LANGUAGE_OPTIONS with the leading "no preference" row for a nullable setting. The per-library editor's LANGUAGE_OPTIONS re-export goes with it, so every language dropdown in settings now iterates one list in one shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): show canonical device overrides in admin devices The device detail panel read its override rows from GET /admin/devices/{user}/{device}, whose `settings` array still comes out of the legacy user_device_settings table. The settings-contract migration folded that table into user_setting_values and nothing writes to it any more, so an override created since the cutover — including one the admin had just saved through this very panel — was invisible here, while the migrated rows stayed visible. The panel's own writes go to the canonical route, which made the list look like it silently dropped edits. Read the overrides from the canonical values API instead, filtered to device scope and to this device. Both storage generations show, because the migration moved the legacy rows into the same table. The detail endpoint is still the source for registration metadata — device name, owner, which profiles have used it — which is not a setting and has no canonical equivalent. The override count and last-updated readouts move to the canonical rows for the same reason: override_count is computed over the legacy table and would disagree with the rows rendered underneath it. "Reset all for device" has no bulk canonical route, so it keeps issuing one delete per key, now over the keys that actually exist. The reset button also takes the profile id from the tab rather than from its first row, which a profile registered on the device with no override yet does not have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web): delete the legacy settings hooks hooks/queries/settings.ts spoke the string-only registry API: every value a string, scope implied by which function you called, and an unknown key silently accepted. Phase B moved every consumer onto the canonical value hooks, and the last importer left was the file's own test — so both go together, along with the client functions they were the only callers of. hooks/queries/libraryPlaybackPreferences.ts goes with them. It wrapped GET/PUT/DELETE /library-playback-prefs, which LibrarySettings replaced with profile_library-scoped canonical writes; nothing in web has called it since. The server route stays for now — the Android and Apple clients may still use it — but the web type and query keys have no reason to linger. settingsKeys keeps only `all` (the prefix the canonical invalidation targets) and the plugin entries, which are a different system. The list/detail/deviceDetail/effective builders described the registry's cache layout and had no remaining callers; effectiveSettingsQueryKey in settingValues.ts owns the canonical shape. hooks/useSettingsForm.ts is deliberately untouched: it edits admin server_settings through /admin/settings, which is a separate surface from the per-user contract, and has more than twenty live consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): review pass over the phase B adoption Phase B moved the web client onto the canonical settings surface. Three scope mistakes slipped in, all of the same shape: a value written at a scope no UI can reach, shadowing the one the user can edit. Auto-play next. The post-roll toggle wrote profile_device while Settings → Playback wrote profile, and the contract resolves the device row above the profile row. Turning auto-play off in the player therefore made the settings switch permanently inert — it saved a profile value the device row kept shadowing and snapped straight back, with no web affordance able to clear the device row. Both surfaces now share useAutoPlayNextSetting, which writes the profile and clears any device row (also the only way a migrated per-device override becomes reachable). Before Phase B both writers used useSetDeviceSetting, so they could not disagree; this restores that invariant at the scope the rest of the Playback screen edits. In-player subtitle picks. handleSubtitleChanged wrote three canonical keys at profile_series, the top of the resolution ladder, while "Auto" on the item page still deleted only the legacy /subtitle-prefs row — so the reset silently stopped working and the abandoned language kept resolving for every episode of the series, forever. One of the three, show_forced_subtitles, was worse: the player has no forced-subtitle control, so the value it wrote back was the *resolved* one, which for a viewer who never expressed a preference is the contract default. That pinned the default above the profile-scope toggle on the Subtitles screen. The written set now comes from SERIES_SUBTITLE_SETTING_KEYS — language and mode only, both derived from the user's actual choice — and useDeleteSubtitlePreference clears exactly that list, so the writer and the reset cannot drift. show_forced_subtitles still rides the legacy composite row, which is keyed to a concrete track selection and is not part of the canonical ladder. Admin user settings. The tab now lists every non-device canonical row, which includes the object-valued profile settings (sidebar pins, card overlays, disabled libraries, library order, custom theme vars). It gated only on `definition`, and controlKindFor has no `object` branch, so those fell through to RegistrySettingControl's select — rendering a user's pins as a one-entry "Unset" dropdown whose only option nulls them. It now uses the same isStructuredSetting guard the device tab got, routing them to a raw JSON editor. Tests: each fix has a test that fails without it, verified by reverting the fix in place. The auto-play and subtitle tests resolve through lib/settingsResolve rather than a canned answer, so the scope-precedence assertions exercise the real ladder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): serve profile preference fields from canonical resolution PUT /settings/values?scope=profile writes only user_setting_values, but GET /profiles still served the legacy user_profiles columns. A preference saved through the canonical API was therefore invisible in every profile DTO reader on every platform — Apple's shipped build reads exactly those fields — while profiles_settings_sync.go mirrored one way only, legacy column write to canonical row. Serve those five fields (language, preferred_metadata_language, subtitle_language, subtitle_mode, show_forced_subtitles) by resolving their canonical keys through the settingsresolve seam at profile scope, falling back to the contract default rather than to the stale column. This matches the cutover direction taken everywhere else: the legacy columns stay written but stop being read, so "clear this preference" cannot resurface a pre-cutover value the one-time backfill already converted. The write paths that accept these fields and mirror them are unchanged; this is read-side only, and the DTO's field names and types are untouched. Resolution is batched. A profile list serves the whole household, so SettingResolutionQuery.ProfileID becomes ProfileIDs and the new Resolver.ResolveProfiles ranks every profile against one candidate set — one store read per list request instead of one per profile. Both backends carry the widened predicate and the shared storetest conformance suite gains a household case, so they cannot drift on it. quality_preference stays column-backed: the legacy column is one compound value while the contract splits it across playback.preferred_quality and playback.max_bitrate_kbps, so there is no lossless read. The auto_skip_* and auto_play_next_preview fields stay column-backed too — the sync path never mirrored them, so their canonical rows can lag the columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): repair CI findings after the main merge CI runs checks the local loop does not: golangci-lint (not installed here) flagged two unchecked Close errors in the new websocket test, and tsc -b (the tests were only vitest-run locally) rejected strict indexed-access in four test files touched by the review passes. The merge also brought main's onboarding tour, whose SettingControl wrote through the legacy useSetSetting hook this branch deletes — it now writes the canonical scoped mutation, re-typing the tour's string values through the generated contract like the admin surface does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(settings): satisfy the incremental lint pass golangci-lint reports findings incrementally, so these three surfaced only after the previous fix: errors.Is for the pgx.ErrNoRows compare (wrapped errors), and named constants for the repeated "values" response key and the "usersettings" prefs id goconst flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(database): pin the read value when deleting moved displayprefs rows Under READ COMMITTED the move's DELETE takes its own snapshot, so during a rolling deploy an old-binary instance could update a jellycompat row between the migration's SELECT and its delete — and the (user_id, key) predicate would destroy the newer value after copying only the older one. Naming the value the transaction actually read makes such a row survive as a stranded legacy row instead, the same disposition a late-inserted row already had. Extends the concurrent-write migration test to commit an update to an already-read row during the stall and assert the newer value survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): make canonical mutation writes honest under failure Three review findings on the canonical settings endpoints: - Idempotency receipts were recorded via defer, so a failed upsert still left a receipt and the client's retry replayed a success for a write that never happened. The receipt is now written only after the upsert lands, and it stores the actual response — revision and updated_at included — so a replay is byte-identical instead of a reconstruction of the input with revision 0. - The mutation envelope accepted trailing JSON after the first document, leaving the interpreted mutation parser-dependent. The decoder now requires EOF after the envelope. - Resolving a device-aware key without X-Silo-Device-Id silently skipped every stored device override and passed the profile fallback off as the effective value. The effective endpoint now fails closed with 400, matching the write path's existing requirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stop the contract rejecting values shipped clients store Four bounds in the contract were narrower than what a shipped client already produces, so real stored preferences would fail validation or be quarantined at migration: - The BCP 47 grammar rejected extlang tags (zh-cmn) and private-use-only tags (x-private) the legacy length-only validator accepted, turning an existing 204 into a 400. The pattern now covers both, and NormalizeLanguageTag cases a script correctly after an extlang and leaves private-use content lowercase. - subtitle_appearance.fontFamily allowlisted ASCII, contradicting its own description: Apple clients store CTFontManager family names verbatim and those are routinely CJK. The pattern now excludes unsafe characters instead of allowlisting ASCII. - theme-var-overrides capped CSS values at 128 characters, which real multi-stop gradients exceed; the web importer stores them unchecked. Raised to 1024. Plus one tightening the review asked for: card-overlays.order now declares uniqueItems, matching library-id-list, so an overlay cannot be rendered twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(userstore): reject non-canonical identities and bound resolution batches Three review findings on the canonical settings storage layer: - SettingIdentity.Validate trimmed ids only to check emptiness, so a padded id like " p1 " validated, persisted verbatim, and was then invisible to resolution queries, which bind trimmed forms — a silently orphaned row. Validation now rejects any id that is not in canonical trimmed form, pinned in the shared conformance suite so both backends hold the line. - The effective-values endpoint accepted unbounded library_ids and series_ids lists; the SQLite backend expands each id into a bound parameter, so a crafted batch could exhaust the host-parameter budget and fail the whole resolution. The request boundary now caps the combined content ids at 200. - pickForScope's doc comment promised ties broken "by the most specific id in the request order" while the implementation sorts by ascending library then series id; the comment now describes the actual (deliberately deterministic-only) behavior. Plus: the pgstore conformance cleanups now assert the ON DELETE CASCADE they rely on instead of discarding the delete error, so a dropped FK can no longer leak seeded rows into the shared test database silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the discovery gaps around the canonical API Three review findings: - Canonical profile_device writes never touched the device registry, so a device that only ever wrote through /settings/values was invisible to ListDevices and the admin device surfaces — undiscoverable and unforgettable. Device-scope writes now refresh the registry from the request's device headers, throttled the same way the legacy route is. - The contract spec tells clients to probe GET /settings/manifest (and /settings/capability), and to read a 404 as "pre-contract server"; the router only exposed /settings/contract*. The documented paths now alias the same handlers. - The plugin proxy's X-Silo-Theme header came from the legacy account-level user_settings.ui_theme row, so a profile's theme change through the canonical API never reached plugins and profiles sharing an account were indistinguishable. The lookup now resolves the canonical profile-scoped ui.theme row (falling back to the legacy row for stores the backfill has not covered) using the request's active profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): emit revision metadata in generated bindings and verify the TS one Two review findings on the generator surface: - The bindings dropped every introduced_in tag, so a client generated from revision N could not filter its pinned contract down to an older server's advertised revision — the promised negotiation had no data. The TypeScript definitions now carry introducedIn per definition, per scope, per enum member, and the full history of any widened numeric bound. (Go/Kotlin/Swift emit keys, not definition tables, so they only need the Revision constant they already have.) - make verify-settings-bindings compared only the generated Go file and the conformance fixture, so a manifest change could merge with a stale web/src/lib/settingsContract.ts. The target now regenerates and diffs the TypeScript binding too, through the same prettier config the bindings target applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop the accidentally committed settingsgen binary24ee9952checked in a 5.5 MB compiled settingsgen alongside its source. The binary is a local build artifact — cmd/settingsgen is the source of truth and make settings-bindings runs it with go run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the migration planner's data-loss and crash findings Four review findings on the one-time legacy-to-canonical migration: - A device holding both player.next_up_prompt_seconds and its playback.* rename canonicalized to one identity, and both backends insert bare — a unique violation that failed NewUserDB (SQLite) or aborted the goose migration (Postgres). Plan now ends with a deterministic dedup keyed on the canonical identity; a canonically keyed row beats a renamed alias, since the runtime writes the canonical spelling first and only best-effort-deletes the alias. - The four auto-skip profile columns (auto_skip_intro/credits/recap, auto_play_next_preview) were never read, so an explicit true silently became the contract default false. They now migrate — explicit true only, so an untouched false column does not become a choice. - Profiles with language 'en' emitted no playback.audio_language row because the column default was suppressed, but that default WAS the effective behavior: the old playback path preferred English, while the canonical null default skips language matching entirely. English now migrates as an explicit row. The other suppressed defaults stay suppressed — their empty-string defaults already meant unset. - Stored v1 card_overlays documents were quarantined because the planner validated them against the v2-only schema; the web parser has upgraded v1 at read time all along. The planner now applies the same v1-to-v2 upgrade before validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): delete canonical library-scoped values with the library The canonical settings schema deliberately has no FK on library_id or series_id, and the migration comment promised the owning delete paths would clean these rows up — but nothing called DeleteSettingValuesForLibrary/-Series outside stores and tests, so a deleted library left orphaned profile_library preferences in every user's store forever. Adds userstore.SettingValuesCleaner, a per-user best-effort sweep in the mutation-sweeper's mold, and wires it into the library delete job. The series-side cleanup is exposed on the same cleaner for the scanner's orphan pruning to adopt; series have no single delete executor today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): keep off-step playback speeds working until cutover The legacy device endpoint gained step enforcement mid-branch, turning an existing in-range PUT of 0.26 from 204 into 400 — a behavior change on a live /api/v1 endpoint before the coordinated break, which the v1 rules forbid. The legacy validator is back to range-only; the typed mutation endpoint keeps enforcing the manifest's step. The migration planner now snaps stored off-step numbers onto their definition's step grid instead of quarantining them: a stored 0.26 is a real preference, and every client's stepper was going to snap it on the next write anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): serve canonical values to the readers the cutover stranded Four P1 review findings where the web writes canonical rows the server never reads — and the legacy keys those readers use are now unwritable, so the values are frozen and user edits silently do nothing: - access.DisabledLibraryIDs and the policy viewer resolver read the legacy account key while the library screen writes profile-scoped ui.disabled_library_ids. Both now resolve the canonical profile row, falling back to the legacy key only when no canonical row exists. - The sections fetcher and handler read the legacy next_up_mode account key while the playback screen writes ui.next_up_mode. Same ladder, behind one shared sections.NextUpMode helper. - Profile creation committed the profile and then synced settings non-atomically, so a mid-sync failure left a profile the retry could not recreate (name conflict) with preferences that read as contract defaults forever. The create path now compensates by deleting the profile it created. - The mounted legacy PUT /subtitle-prefs/{series_id} wrote only user_subtitle_preferences, but item detail resolves those three keys canonically, so a post-upgrade client's "subtitles off" returned 204 and was ignored. The legacy handler now dual-writes the canonical profile_series rows, and its delete clears them. Plus the migration's disposition for stranded Apple device-scope audio language rows: nothing read them before the contract, so promoting them to real overrides would change track selection at upgrade. They are recorded in the rejects table instead of copied. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): make canonical settings writes take effect Three P1 review findings on the web half of the cutover: - Every profile-default editor reads the resolved value but writes the profile row, so a device override — left by the migration converting legacy user_device_settings, or written by another client — kept shadowing the save and snapped the control back with no affordance to remove it. useAutoPlayNextSetting already solved this for one key; that logic is now a shared useProfileDefaultWriter used by the playback screen, the quality picker, subtitle behavior, and the four appearance setters. It only clears when the key is device-scopable and the resolved value actually came from a device row. - The appearance cache only ever grew: when the effective response resolved a key to "default" — because another client deleted it — the namespaced entry and local state survived and kept winning the fallback, so a removal never reached this browser. The mirror now runs both ways, clearing only on an explicit default answer (silence is not a deletion) and only within the current identity's namespace. Custom theme vars and CSS do the same, except while a local draft is unsaved. - The quality picker wrote the canonical two-axis keys while playback still derived its cap from currentProfile.quality_preference, a legacy compound column the canonical write deliberately does not mirror — so choosing a quality changed nothing about what played. The watch route and both item-detail pages now read playback.preferred_quality, falling back to the profile column until the settings read resolves so playback never blocks on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): repair the type error and the bindings gate's job placement Two breaks from the previous commits: - useTheme referenced storage.StorageKey, but storage is a value, not a namespace — the Web job's tsc caught what the local incremental typecheck had already cached past. Imported the type properly. - verify-settings-bindings gained a prettier step, and the Go job that runs it has no pnpm, so the check failed on its own tooling rather than on a stale binding. Split the web half into verify-settings-bindings-web and moved it to the Web job, which has pnpm; verify-settings-bindings-all runs both locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): close the second-round review findings Three from the review of the pushed work: - The live profile sync omitted auto_skip_intro/credits/recap and auto_play_next_preview, which my own change made load-bearing: the player now resolves those keys canonically, so a legacy PUT /profiles moved the columns, returned 200, and changed nothing about playback. All four now mirror on write. The DTO read block keeps its shape — clients pin it — and its columns are what the sync keeps current. - The effective endpoint dropped unknown keys silently, letting a client fill the gap with its own vendored default and present a value this server would refuse to store. Unknown keys now 404 by name. - Two sidebar-pin toggles in flight at once could commit in either order, and the server upsert is last-write-wins, so the first request landing second restored the pre-toggle document. The writes are now chained, and each link reads the document when it runs, so a queued toggle sends the newest state rather than the one it was queued with. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(database): make the settings-contract deploy reversible Rolling back this release meant restoring a backup, for a reason that was not obvious: the DisplayPreferences move deletes the jellycompat rows from user_settings once it has copied them, and the previous binary reads exactly those rows. An older server therefore starts cleanly and silently serves defaults, so every Jellyfin client's saved view preferences look reset. The down functions were already written and correct — nothing could invoke them. The backfill and the DisplayPreferences move are Go migrations registered in-process, so the standalone goose CLI in the Makefile cannot see them, and the server exposed only --migrate-only and --migrate-status. Adds MigrateDownTo, the --migrate-down-to flag, and a make target, plus a rehearsal test that seeds a legacy row the way the old binary wrote it, applies the move, rolls back, and asserts the row returns byte-for-byte. Documents the ordering in the spec's cutover section, including the two caveats an operator needs beforehand: take a backup, and the per-user SQLite backend cannot be rolled back at all — its migrations have no down path and an older binary refuses to open a newer database, so those installs restore from backup rather than degrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): skip legacy rows whose profile was deleted The dev-server migration aborted on real data: writing playback.subtitle_appearance at profile_device for user 1: violates foreign key constraint user_setting_values_profile_fkey user_device_settings carries an ON DELETE CASCADE on (user_id, profile_id) today, but rows written before that constraint outlived the profiles they belonged to — that install had 46 such rows across 14 deleted profiles. The planner copied them faithfully and the canonical table, which declares the same foreign key, refused them; because the backfill runs in one transaction, the whole migration failed and the server could not start. An override belonging to a profile nobody can select is not a preference anyone can be shown or reset, so Plan now drops those rows rather than repairing them, recording each in user_setting_migration_rejects so an operator can see what was left behind. Account-scope rows carry no profile and pass through untouched. Verified by replaying that install's 514 device rows through the planner: 9 rows would have hit the constraint before, 0 after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): address canonical cutover review findings * fix(settings): address latest review findings --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
4028 lines
154 KiB
Go
4028 lines
154 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"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/config"
|
||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||
"github.com/Silo-Server/silo-server/internal/httpstream"
|
||
"github.com/Silo-Server/silo-server/internal/markers"
|
||
"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/settingscontract"
|
||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||
"github.com/Silo-Server/silo-server/internal/watchstate"
|
||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||
)
|
||
|
||
// SessionManagerInterface defines the operations the PlaybackHandler needs
|
||
// on the session manager.
|
||
type SessionManagerInterface interface {
|
||
StartSession(userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
StartSessionWithFiles(userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
UpdateProgress(sessionID string, position float64, isPaused bool) error
|
||
UpdateAudioTrack(sessionID string, audioTrackIndex int, method playback.PlayMethod) error
|
||
UpdateStreamState(sessionID string, state playback.SessionStreamState) error
|
||
TouchActivity(sessionID string) error
|
||
BeginTransport(sessionID string) error
|
||
EndTransport(sessionID string) error
|
||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||
SetTranscodeNodeURL(sessionID, url string) error
|
||
SetTranscodeRoute(sessionID string, route playback.TranscodeRoute) error
|
||
ApplyReplacement(sessionID string, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, error)
|
||
ApplyReplacementIfRoute(sessionID string, expected playback.TranscodeRoute, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, bool, error)
|
||
RollbackReplacement(sessionID string, rollback playback.SessionReplacementRollback) error
|
||
SetWebSocket(sessionID string, connected bool) error
|
||
SetRealtimeConnection(sessionID string, connected bool) error
|
||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||
StopSession(sessionID string) error
|
||
GetSession(sessionID string) (*playback.Session, error)
|
||
}
|
||
|
||
type sessionStarterWithFilesContext interface {
|
||
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
}
|
||
|
||
type transcodePermissionChecker interface {
|
||
CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error
|
||
}
|
||
|
||
func (h *PlaybackHandler) ensureUserTranscodingAllowed(w http.ResponseWriter, r *http.Request, userID int, requiresVideoTranscode bool) bool {
|
||
checker, ok := h.sessionMgr.(transcodePermissionChecker)
|
||
if !ok {
|
||
return true
|
||
}
|
||
if err := checker.CheckTranscodingAllowed(r.Context(), userID, requiresVideoTranscode); err != nil {
|
||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||
return false
|
||
}
|
||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||
return false
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify transcoding access")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
type PlaybackItemAccessChecker interface {
|
||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||
}
|
||
|
||
type PlaybackEpisodeLookup interface {
|
||
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
|
||
}
|
||
|
||
// PlaybackExtraLookup resolves local extras (media_extras) so their files
|
||
// authorize through the parent item, like episodes authorize through their
|
||
// series.
|
||
type PlaybackExtraLookup interface {
|
||
GetByID(ctx context.Context, contentID string) (*models.MediaExtra, error)
|
||
}
|
||
|
||
type PlaybackSessionSyncer interface {
|
||
SyncNow(ctx context.Context) error
|
||
}
|
||
|
||
// PlaybackSettingsReader reads server settings for playback decisions.
|
||
type PlaybackSettingsReader interface {
|
||
Get(ctx context.Context, key string) (string, error)
|
||
}
|
||
|
||
// PlaybackFileVersionFetcher retrieves alternate file versions for a content item.
|
||
type PlaybackFileVersionFetcher interface {
|
||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
|
||
}
|
||
|
||
type PlaybackProbeEnsurer interface {
|
||
Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error)
|
||
}
|
||
|
||
type PlaybackChapterThumbnailQueuer interface {
|
||
QueuePriorityFileAtPosition(ctx context.Context, fileID int, targetSeconds float64)
|
||
}
|
||
|
||
// PlaybackOriginalLanguageLookup fetches the original language for a content item.
|
||
type PlaybackOriginalLanguageLookup interface {
|
||
GetOriginalLanguage(ctx context.Context, contentID string) (string, error)
|
||
}
|
||
|
||
type copySeekAnchorResolver func(
|
||
ctx context.Context,
|
||
ffmpegPath string,
|
||
inputPath string,
|
||
requestedSeekSeconds float64,
|
||
segmentDuration int,
|
||
) (float64, int, error)
|
||
|
||
// PlaybackHandler handles playback session HTTP endpoints.
|
||
type PlaybackHandler struct {
|
||
sessionMgr SessionManagerInterface
|
||
fileResolver FilePathResolver // optional; enables stream_url in responses
|
||
StoreProvider userstore.UserStoreProvider // optional; enables progress/history persistence
|
||
WatchScrobbler PlaybackWatchScrobbler
|
||
StableIdentityResolver *watchstate.StableIdentityResolver
|
||
CompletionObserver watchstate.CompletionObserver // optional; auto-removes watched items from the watchlist
|
||
profileStaler ProfileStaler
|
||
profileRefreshRequester ProfileRefreshRequester
|
||
AdminStore PlaybackAdminStore // optional; enables admin playback history/live session cleanup
|
||
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
||
EventsHub *evt.Hub
|
||
MissingMarker MissingFileMarker
|
||
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
|
||
JWTSecret string // needed for signing stream tokens
|
||
ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks
|
||
EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series
|
||
ExtraLookup PlaybackExtraLookup // optional; resolves extras files to their parent item
|
||
OriginalLangLookup PlaybackOriginalLanguageLookup
|
||
SettingsRepo PlaybackSettingsReader // optional; reads server settings (e.g., allow_4k_transcode)
|
||
FileVersionFetcher PlaybackFileVersionFetcher // optional; queries sibling file versions for 4K guard
|
||
ProbeEnsurer PlaybackProbeEnsurer // optional; repairs missing probe metadata on demand
|
||
ChapterThumbnailQueuer PlaybackChapterThumbnailQueuer
|
||
IntroAnalyzer IntroEpisodeAnalyzer
|
||
IntroRepository PlaybackIntroEligibilityChecker
|
||
MarkerRegistry *markers.Registry
|
||
MarkerResolver markers.ExternalIDResolver
|
||
MarkerUpserter PlaybackMarkerUpserter
|
||
MarkerUpdateNotifier PlaybackMarkerUpdateNotifier
|
||
MarkerLazyContext context.Context
|
||
MarkerLazyInFlight sync.Map
|
||
SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles in playback
|
||
RealtimeHub *playback.RealtimeHub
|
||
CommandTracker *playback.CommandTracker
|
||
CommandDispatcher *playback.CommandDispatcher
|
||
// PlaybackConfig returns the current playback config (ffmpeg path,
|
||
// hwaccel, transcode dir). Wired to the live config in integrated mode
|
||
// so admin changes apply to newly started transcodes. Read it through
|
||
// playbackConfig(), which falls back to defaults when unset.
|
||
PlaybackConfig func() config.PlaybackConfig
|
||
FFmpegLogSink playback.FFmpegLogSink
|
||
copySeekAnchor copySeekAnchorResolver
|
||
realtimeCommandMu sync.Mutex
|
||
realtimeCommands map[string]playbackCommandRecord
|
||
// tm owns the transcode-session lifecycle (live map, recipe cards, and
|
||
// 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 {
|
||
ScrobbleStart(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
ScrobblePause(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
ScrobbleStop(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
}
|
||
|
||
type sessionExpirationHookSetter interface {
|
||
SetExpirationHook(func(*playback.Session))
|
||
}
|
||
|
||
// NewPlaybackHandler creates a new PlaybackHandler backed by the given
|
||
// session manager. Pass optional FilePathResolver to enable stream_url
|
||
// and subtitle_urls in start playback responses.
|
||
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
|
||
h := &PlaybackHandler{
|
||
sessionMgr: sessionMgr,
|
||
realtimeCommands: make(map[string]playbackCommandRecord),
|
||
tm: playback.NewTranscodeManager(),
|
||
PlanStoreV3: playback.NewMemoryPlanStoreV3(),
|
||
}
|
||
if len(opts) > 0 {
|
||
h.fileResolver = opts[0]
|
||
}
|
||
// Wire the shared transcode manager with closures so it reads the handler's
|
||
// (often late-set) config/store/secret fields lazily at call time, avoiding a
|
||
// field-ordering hazard during router setup.
|
||
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
|
||
h.tm.LogSinkFn = func() playback.FFmpegLogSink { return h.FFmpegLogSink }
|
||
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
||
c := h.playbackConfig()
|
||
return playback.TranscodeRuntimeConfig{
|
||
TranscodeDir: c.TranscodeDir,
|
||
FFmpegPath: c.FFmpegPath,
|
||
HWAccel: c.HWAccel,
|
||
HWDevice: c.HWDevice,
|
||
}
|
||
}
|
||
h.tm.StartThrottler = func(ctx context.Context, ts *playback.TranscodeSession) {
|
||
h.maybeStartThrottler(ctx, ts)
|
||
}
|
||
h.tm.OnFFmpegCrash = func(ctx context.Context, sessionID string, dead *playback.TranscodeSession) {
|
||
// ffmpeg crash — tear the session down; a client holding a valid stream
|
||
// token can reconstruct it on the next request.
|
||
//
|
||
// Compare-and-delete the dead transcode first: between ffmpeg's error exit
|
||
// and this teardown a reconstruct may have registered a fresh successor
|
||
// under the same id. CloseTranscodeSessionIf only removes (and Close()s, which
|
||
// reaps the shared output dir) the entry when it is still the dead session;
|
||
// if a successor won, it leaves the live one untouched and we must NOT tear
|
||
// down the reconstructed playback session that now backs it.
|
||
var nodeURL string
|
||
if s, err := h.sessionMgr.GetSession(sessionID); err == nil {
|
||
nodeURL = s.TranscodeNodeURL
|
||
}
|
||
if successor := h.tm.GetTranscodeSession(sessionID); successor != nil && successor != dead {
|
||
// A reconstruct already replaced the crashed process; the live successor
|
||
// and its session stand. Cheap fast-path only — the authoritative gate is
|
||
// the compare-and-delete result below.
|
||
return
|
||
}
|
||
// CloseTranscodeSessionIf is the authoritative gate: a successor may register
|
||
// under the same id between the pre-check above and here. We only tear down the
|
||
// upstream playback session when the compare-and-delete actually matched the
|
||
// dead transcode. When it returns false a successor owns the session — do
|
||
// nothing further, or finalizeSessionStop's unconditional CloseTranscodeSession
|
||
// would reap the live successor's output dir mid-serve.
|
||
if !h.tm.CloseTranscodeSessionIf(sessionID, dead, nodeURL) {
|
||
return
|
||
}
|
||
if err := h.stopPlaybackSessionByID(ctx, sessionID, false); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
|
||
slog.ErrorContext(ctx, "failed to stop playback after local transcode exit", "component", "api", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
if reg, ok := sessionMgr.(interface {
|
||
RegisterReconstructed(s *playback.Session) *playback.Session
|
||
RegisterReconstructedWithLimits(ctx context.Context, s *playback.Session) (*playback.Session, error)
|
||
}); ok {
|
||
h.tm.Sessions = reg
|
||
}
|
||
if setter, ok := sessionMgr.(sessionExpirationHookSetter); ok {
|
||
setter.SetExpirationHook(h.handleExpiredSession)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// TranscodeManager returns the shared transcode/reconstruct manager so sibling
|
||
// handlers (e.g. StreamHandler) can reuse the same recipe-card store, live
|
||
// transcode map, and reconstruct front door rather than wiring a second one.
|
||
func (h *PlaybackHandler) TranscodeManager() *playback.TranscodeManager {
|
||
return h.tm
|
||
}
|
||
|
||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||
func (h *PlaybackHandler) SetProfileStaler(ps ProfileStaler) {
|
||
h.profileStaler = ps
|
||
}
|
||
|
||
// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles.
|
||
func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) {
|
||
h.profileRefreshRequester = requester
|
||
}
|
||
|
||
// playbackConfig returns the current playback config, falling back to the
|
||
// same defaults as config loading (transcode enabled, temp transcode dir)
|
||
// when no provider is wired (tests, minimal setups).
|
||
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
|
||
if h.PlaybackConfig != nil {
|
||
return h.PlaybackConfig()
|
||
}
|
||
return config.PlaybackConfig{
|
||
TranscodeEnabled: true,
|
||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||
}
|
||
}
|
||
|
||
// CleanupOrphanedTranscodes removes stale per-session temp directories for
|
||
// transcodes that are no longer tracked in memory, sparing dirs whose recipe
|
||
// card still exists. Delegates to the shared transcode manager.
|
||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||
return h.tm.CleanupOrphanedTranscodes()
|
||
}
|
||
|
||
// playbackThresholds reads the playback.watched_threshold and
|
||
// playback.min_resume_threshold settings. Zero values mean "use defaults".
|
||
func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.ProgressThresholds {
|
||
if h.SettingsRepo == nil {
|
||
return userstore.ProgressThresholds{}
|
||
}
|
||
var t userstore.ProgressThresholds
|
||
if v, _ := h.SettingsRepo.Get(ctx, "playback.watched_threshold"); v != "" {
|
||
if pct, err := strconv.Atoi(v); err == nil && pct > 0 {
|
||
t.WatchedPct = pct
|
||
}
|
||
}
|
||
if v, _ := h.SettingsRepo.Get(ctx, "playback.min_resume_threshold"); v != "" {
|
||
if pct, err := strconv.Atoi(v); err == nil && pct > 0 {
|
||
t.MinResumePct = pct
|
||
}
|
||
}
|
||
return t
|
||
}
|
||
|
||
// --- Request/Response types ---
|
||
|
||
// hdrDetails describes granular HDR support advertised by the client.
|
||
// Optional — absent means the resolver falls back to the boolean HDR flag.
|
||
// Dolby Vision profile numbers follow MediaCodec:
|
||
//
|
||
// 5 = DvheStn / DvheSt (single-layer)
|
||
// 7 = DvheDtb / DvheDtr / DvheDth (dual-layer BL+EL — needs multi-instance)
|
||
// 8 = DvheSt4k / DvavSe
|
||
type hdrDetails struct {
|
||
HDR10 bool `json:"hdr10"`
|
||
HDR10Plus bool `json:"hdr10_plus"`
|
||
HLG bool `json:"hlg"`
|
||
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
|
||
}
|
||
|
||
// audioPassthroughCapabilities describes what the connected audio sink can
|
||
// decode bit-exact. Distinct from `codecs_audio`, which describes what the
|
||
// client can decode itself. Passthrough codecs come from `AudioCapabilities`
|
||
// (HDMI EDID / Bluetooth / USB DAC capability probing on Android; equivalent
|
||
// on iOS/tvOS).
|
||
type audioPassthroughCapabilities struct {
|
||
PassthroughCodecs []string `json:"passthrough_codecs"`
|
||
SpatializerEnabled bool `json:"spatializer_enabled"`
|
||
MaxChannels int `json:"max_channels"`
|
||
}
|
||
|
||
// startPlaybackRequest represents the JSON body for POST /playback/start.
|
||
type startPlaybackRequest struct {
|
||
FileID int `json:"file_id"`
|
||
ProfileID string `json:"profile_id"`
|
||
PlayMethod string `json:"play_method"`
|
||
StartPosition *float64 `json:"start_position,omitempty"`
|
||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||
CodecsVideo []string `json:"codecs_video"`
|
||
CodecsAudio []string `json:"codecs_audio"`
|
||
Containers []string `json:"containers"`
|
||
MaxResolution string `json:"max_resolution"`
|
||
HDR bool `json:"hdr"`
|
||
HdrDetails *hdrDetails `json:"hdr_details,omitempty"`
|
||
AudioPassthrough *audioPassthroughCapabilities `json:"audio_passthrough,omitempty"`
|
||
SupportsBitmapSubtitleBurnIn bool `json:"supports_bitmap_subtitle_burn_in,omitempty"`
|
||
}
|
||
|
||
// progressRequest represents the JSON body for POST /playback/{session_id}/progress.
|
||
type progressRequest struct {
|
||
Position float64 `json:"position"`
|
||
IsPaused bool `json:"is_paused"`
|
||
}
|
||
|
||
// playbackSessionResponse represents a playback session in JSON responses.
|
||
type playbackSessionResponse struct {
|
||
SessionID string `json:"session_id"`
|
||
UserID int `json:"user_id"`
|
||
ProfileID string `json:"profile_id"`
|
||
MediaFileID int `json:"media_file_id"`
|
||
PlayMethod string `json:"play_method"`
|
||
Position float64 `json:"position"`
|
||
IsPaused bool `json:"is_paused"`
|
||
StreamURL string `json:"stream_url"`
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
DurationSeconds *float64 `json:"duration_seconds"`
|
||
SubtitleURLs []subtitleURL `json:"subtitle_urls,omitempty"`
|
||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||
}
|
||
|
||
type playbackInfoResult struct {
|
||
StreamType string `json:"stream_type"`
|
||
TranscodeAudio bool `json:"transcode_audio"`
|
||
VideoCodec string `json:"video_codec"`
|
||
AudioCodec string `json:"audio_codec"`
|
||
}
|
||
|
||
// subtitleURL represents a subtitle track URL in a playback response.
|
||
type subtitleURL struct {
|
||
Index int `json:"index"`
|
||
MediaFileID int `json:"media_file_id,omitempty"`
|
||
Language string `json:"language"`
|
||
Codec string `json:"codec,omitempty"`
|
||
Label string `json:"label"`
|
||
Source string `json:"source"`
|
||
Forced bool `json:"forced"`
|
||
HearingImpaired bool `json:"hearing_impaired"`
|
||
URL string `json:"url"`
|
||
FontBundleURL string `json:"font_bundle_url,omitempty"`
|
||
}
|
||
|
||
// changeAudioRequest represents the JSON body for PATCH /playback/{session_id}/audio.
|
||
type changeAudioRequest struct {
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
Position float64 `json:"position"`
|
||
}
|
||
|
||
// changeAudioResponse represents the JSON response for PATCH /playback/{session_id}/audio.
|
||
type changeAudioResponse struct {
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
PlayMethod string `json:"play_method"`
|
||
StreamURL string `json:"stream_url"`
|
||
SwitchMode string `json:"switch_mode"`
|
||
PlayerStartSeconds *float64 `json:"player_start_seconds,omitempty"`
|
||
StreamOriginSeconds *float64 `json:"stream_origin_seconds,omitempty"`
|
||
TimelineOffsetSeconds *float64 `json:"timeline_offset_seconds,omitempty"`
|
||
CanSeekAnywhere *bool `json:"can_seek_anywhere,omitempty"`
|
||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||
}
|
||
|
||
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
|
||
playerStart := max(0, position-origin)
|
||
canSeekAnywhere := false
|
||
resp.PlayerStartSeconds = &playerStart
|
||
resp.StreamOriginSeconds = &origin
|
||
resp.TimelineOffsetSeconds = &origin
|
||
resp.CanSeekAnywhere = &canSeekAnywhere
|
||
}
|
||
|
||
type transcodeStartRequest struct {
|
||
SessionID string `json:"session_id"`
|
||
SeekSeconds float64 `json:"seek_seconds"`
|
||
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"`
|
||
SubtitleTrackIndex int `json:"subtitle_track_index"`
|
||
SubtitleMediaFileID int `json:"subtitle_media_file_id,omitempty"`
|
||
SubtitleBurnIn bool `json:"subtitle_burn_in"`
|
||
}
|
||
|
||
type transcodeStartResponse struct {
|
||
SessionID string `json:"session_id"`
|
||
Status string `json:"status"`
|
||
SwitchedFileID *int `json:"switched_file_id,omitempty"`
|
||
ManifestURL string `json:"manifest_url"`
|
||
DurationSeconds *float64 `json:"duration_seconds"`
|
||
PlayerStartSeconds float64 `json:"player_start_seconds"`
|
||
StreamOriginSeconds float64 `json:"stream_origin_seconds"`
|
||
TimelineOffsetSeconds float64 `json:"timeline_offset_seconds"`
|
||
CanSeekAnywhere bool `json:"can_seek_anywhere"`
|
||
}
|
||
|
||
// toPlaybackSessionResponse converts a playback.Session to an API response.
|
||
func (h *PlaybackHandler) toPlaybackSessionResponse(s *playback.Session) playbackSessionResponse {
|
||
return playbackSessionResponse{
|
||
SessionID: s.ID,
|
||
UserID: s.UserID,
|
||
ProfileID: s.ProfileID,
|
||
MediaFileID: s.MediaFileID,
|
||
PlayMethod: string(semanticPlayMethod(s)),
|
||
Position: s.Position,
|
||
IsPaused: s.IsPaused,
|
||
StreamURL: h.playbackStreamURL(s),
|
||
AudioTrackIndex: s.AudioTrackIndex,
|
||
}
|
||
}
|
||
|
||
func semanticPlayMethod(s *playback.Session) playback.PlayMethod {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
if s.BasePlayMethod != "" {
|
||
return s.BasePlayMethod
|
||
}
|
||
return s.PlayMethod
|
||
}
|
||
|
||
func fileDurationSeconds(file *models.MediaFile) *float64 {
|
||
if file == nil || file.Duration <= 0 {
|
||
return nil
|
||
}
|
||
duration := float64(file.Duration)
|
||
return &duration
|
||
}
|
||
|
||
func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
|
||
if file == nil || file.Duration <= 0 {
|
||
return false
|
||
}
|
||
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
|
||
// seeks within the currently exposed window. Out-of-window seeks should
|
||
// restart explicitly instead of relying on segment 404s to move FFmpeg.
|
||
return !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
}
|
||
|
||
func buildTranscodeStartResponse(
|
||
req transcodeStartRequest,
|
||
file *models.MediaFile,
|
||
switchedFileID *int,
|
||
manifestURL string,
|
||
streamOriginSeconds float64,
|
||
) transcodeStartResponse {
|
||
resp := transcodeStartResponse{
|
||
SessionID: req.SessionID,
|
||
Status: "started",
|
||
SwitchedFileID: switchedFileID,
|
||
ManifestURL: manifestURL,
|
||
DurationSeconds: fileDurationSeconds(file),
|
||
}
|
||
if canSeekAnywhere(req, file) {
|
||
resp.PlayerStartSeconds = req.SeekSeconds
|
||
resp.StreamOriginSeconds = 0
|
||
resp.TimelineOffsetSeconds = 0
|
||
resp.CanSeekAnywhere = true
|
||
return resp
|
||
}
|
||
resp.PlayerStartSeconds = max(0, req.SeekSeconds-streamOriginSeconds)
|
||
resp.StreamOriginSeconds = streamOriginSeconds
|
||
resp.TimelineOffsetSeconds = streamOriginSeconds
|
||
resp.CanSeekAnywhere = false
|
||
return resp
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveLegacyCopySeekAnchor(
|
||
ctx context.Context,
|
||
ffmpegPath string,
|
||
inputPath string,
|
||
requestedSeekSeconds float64,
|
||
segmentDuration int,
|
||
) (float64, int, error) {
|
||
resolver := h.copySeekAnchor
|
||
if resolver == nil {
|
||
resolver = playback.ResolveCopySeekAnchor
|
||
}
|
||
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
return resolver(probeCtx, ffmpegPath, inputPath, requestedSeekSeconds, segmentDuration)
|
||
}
|
||
|
||
func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models.MediaFile) *models.MediaFile {
|
||
if h == nil || h.ProbeEnsurer == nil || file == nil {
|
||
return file
|
||
}
|
||
repaired, err := h.ProbeEnsurer.Ensure(ctx, file)
|
||
if err != nil {
|
||
slog.WarnContext(ctx, "playback probe repair failed", "component", "api", "file_id", file.ID, "path", file.FilePath, "error", err)
|
||
return file
|
||
}
|
||
if repaired != nil {
|
||
return repaired
|
||
}
|
||
return file
|
||
}
|
||
|
||
// streamTokenParam is the query parameter that carries the signed stream token
|
||
// on the native integrated serve path. The token is the durable reconstruction
|
||
// descriptor: a front-end that lost its in-memory session rebuilds from it. It
|
||
// rides a query parameter (not a path segment) because the integrated server is
|
||
// hit directly by the client — there is no query-stripping proxy hop in between,
|
||
// and the transcode manifest rewriter already appends the request RawQuery to
|
||
// every segment URI, so segment requests inherit the token for free. The
|
||
// proxy/node path keeps the token in the URL path (see the proxy server).
|
||
const streamTokenParam = "st"
|
||
|
||
// signSessionToken mints a stream token carrying the session's full
|
||
// reconstruction recipe. Returns "" when no signing secret is configured
|
||
// (reconstruct effectively disabled, e.g. in tests).
|
||
func (h *PlaybackHandler) signSessionToken(card playback.RecipeCard) string {
|
||
if h.JWTSecret == "" {
|
||
return ""
|
||
}
|
||
token, err := streamtoken.Sign(card.ToClaims(), h.JWTSecret, playback.MaxTokenTTL)
|
||
if err != nil {
|
||
slog.Warn("sign stream token failed", "error", err, "session", card.SessionID, "playback_session_id", card.SessionID)
|
||
return ""
|
||
}
|
||
return token
|
||
}
|
||
|
||
// streamCardFromQuery verifies the stream token in the request's ?st= parameter
|
||
// and returns the decoded reconstruction recipe, or nil when the token is
|
||
// absent, invalid/expired, or bound to a different session. A live session needs
|
||
// no token (the result is simply nil); the recipe is consumed only on
|
||
// reconstruct.
|
||
func (h *PlaybackHandler) streamCardFromQuery(r *http.Request, sessionID string) *playback.RecipeCard {
|
||
return streamCardFromToken(r.URL.Query().Get(streamTokenParam), sessionID, h.JWTSecret)
|
||
}
|
||
|
||
// loadTranscodeServeSession resolves the playback Session for the transcode
|
||
// manifest/segment serve routes while keeping stream-token verification off the
|
||
// hot path. The overwhelmingly common case is a live in-memory session, which
|
||
// needs no token at all, so the cheap GetSession lookup runs first and the
|
||
// (HMAC + JSON) token decode is performed only on a not-found miss where a
|
||
// reconstruct is actually required. On that miss it delegates to the shared
|
||
// LoadOrReconstructSession front door so reconstruct/ownership semantics stay
|
||
// identical. The returned card (nil on the live-session path) is the decoded
|
||
// recipe the caller's own reconstruct branch consumes.
|
||
func (h *PlaybackHandler) loadTranscodeServeSession(r *http.Request, sessionID string) (*playback.Session, playback.SessionLoadStatus, *playback.RecipeCard) {
|
||
requestUserID := apimw.GetUserID(r.Context())
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err == nil {
|
||
// Live session: enforce the same ownership rule as LoadOrReconstructSession
|
||
// (a zero caller is allowed; a non-zero mismatch is refused). No token
|
||
// verification on this hot path.
|
||
if requestUserID != 0 && session.UserID != requestUserID {
|
||
return nil, playback.SessionForbidden, nil
|
||
}
|
||
return session, playback.SessionLoaded, nil
|
||
}
|
||
if !errors.Is(err, playback.ErrSessionNotFound) {
|
||
return nil, playback.SessionLoadFailed, nil
|
||
}
|
||
// Genuine miss (e.g. after a restart): now — and only now — pay for the token
|
||
// decode so the recipe is available for reconstruction.
|
||
card := h.streamCardFromQuery(r, sessionID)
|
||
session, status := h.tm.LoadOrReconstructSession(r.Context(), h.sessionMgr.GetSession, sessionID, requestUserID, card)
|
||
return session, status, card
|
||
}
|
||
|
||
// streamCardFromToken verifies a stream token and decodes its reconstruction
|
||
// recipe, returning nil when the token is absent, unparseable/expired, or bound
|
||
// to a different session id. Shared by the native serve handlers (PlaybackHandler
|
||
// and StreamHandler).
|
||
func streamCardFromToken(tokenStr, sessionID, secret string) *playback.RecipeCard {
|
||
if tokenStr == "" || secret == "" {
|
||
return nil
|
||
}
|
||
claims, err := streamtoken.Verify(tokenStr, secret)
|
||
if err != nil || claims.SessionID != sessionID {
|
||
return nil
|
||
}
|
||
card := playback.RecipeCardFromClaims(claims)
|
||
return &card
|
||
}
|
||
|
||
// appendStreamToken adds the ?st=<token> parameter to a native serve URL.
|
||
func appendStreamToken(rawURL, token string) string {
|
||
if token == "" {
|
||
return rawURL
|
||
}
|
||
sep := "?"
|
||
if strings.ContainsRune(rawURL, '?') {
|
||
sep = "&"
|
||
}
|
||
return rawURL + sep + streamTokenParam + "=" + token
|
||
}
|
||
|
||
// playbackStreamURL builds the native serve URL for a session and appends an
|
||
// identity stream token so a direct-play/remux session survives a restart (the
|
||
// client re-supplies its byte position). Transcode sessions receive their
|
||
// full-recipe manifest URL from HandleStartTranscode instead; the URL here is an
|
||
// informational placeholder the client replaces with that manifest.
|
||
func (h *PlaybackHandler) playbackStreamURL(s *playback.Session) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
if s.PlayMethod == playback.PlayTranscode {
|
||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", s.ID)
|
||
}
|
||
card := identityRecipeCard(s)
|
||
return appendStreamToken(fmt.Sprintf("/stream/%s", s.ID), h.signSessionToken(card))
|
||
}
|
||
|
||
// identityRecipeCard builds the identity-only recipe for a direct-play or remux
|
||
// session: reconstruction needs only ownership plus the audio selection, since
|
||
// the bytes are served by HTTP Range / a re-spawned remux pipe at the
|
||
// client-supplied position.
|
||
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, s.RemuxDVMode)
|
||
default:
|
||
return playback.NewDirectRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID)
|
||
}
|
||
}
|
||
|
||
func fileBitrateKbps(file *models.MediaFile) int {
|
||
if file == nil || file.Bitrate <= 0 {
|
||
return 0
|
||
}
|
||
return file.Bitrate
|
||
}
|
||
|
||
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
|
||
if session == nil {
|
||
return nil
|
||
}
|
||
|
||
info := &playbackInfoResult{
|
||
TranscodeAudio: session.TranscodeAudio,
|
||
}
|
||
|
||
switch session.PlayMethod {
|
||
case playback.PlayTranscode:
|
||
info.StreamType = "hls"
|
||
if strings.EqualFold(session.TargetVideoCodec, "copy") || session.TargetVideoCodec == "" {
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
} else {
|
||
info.VideoCodec = session.TargetVideoCodec
|
||
}
|
||
if session.TranscodeAudio {
|
||
info.AudioCodec = "aac"
|
||
} else if strings.EqualFold(session.TargetAudioCodec, "copy") || session.TargetAudioCodec == "" {
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
} else {
|
||
info.AudioCodec = session.TargetAudioCodec
|
||
}
|
||
case playback.PlayRemux, playback.PlayDirect:
|
||
info.StreamType = "progressive"
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
if session.TranscodeAudio {
|
||
info.AudioCodec = "aac"
|
||
} else {
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
}
|
||
default:
|
||
info.StreamType = "progressive"
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
}
|
||
|
||
return info
|
||
}
|
||
|
||
func requestedMediaFileID(session *playback.Session) int {
|
||
if session == nil {
|
||
return 0
|
||
}
|
||
if session.RequestedMediaFileID > 0 {
|
||
return session.RequestedMediaFileID
|
||
}
|
||
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 sessionTranscodeRoute(session *playback.Session) playback.TranscodeRoute {
|
||
if session == nil {
|
||
return playback.TranscodeRoute{}
|
||
}
|
||
return playback.TranscodeRoute{
|
||
NodeURL: session.TranscodeNodeURL,
|
||
TransportID: session.TranscodeTransportID,
|
||
}
|
||
}
|
||
|
||
const legacyTransportMarker = "-legacy-"
|
||
|
||
func isLegacyTransportSession(session *playback.Session) bool {
|
||
if session == nil {
|
||
return false
|
||
}
|
||
if session.TranscodeTransportID == "" {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(session.TranscodeTransportID, session.ID+legacyTransportMarker)
|
||
}
|
||
|
||
func newLegacyTransportID(sessionID string) string {
|
||
return sessionID + legacyTransportMarker + uuid.NewString()
|
||
}
|
||
|
||
func (h *PlaybackHandler) transcodeRouteMatches(
|
||
sessionID string,
|
||
expectedLocal *playback.TranscodeSession,
|
||
route playback.TranscodeRoute,
|
||
) bool {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
defer unlock()
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil || session == nil {
|
||
return false
|
||
}
|
||
return h.tm.GetTranscodeSession(sessionID) == expectedLocal &&
|
||
sessionTranscodeRoute(session) == route
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyRemoteReplacement(
|
||
sessionID string,
|
||
previousLocal *playback.TranscodeSession,
|
||
previousRoute playback.TranscodeRoute,
|
||
replacement playback.SessionReplacement,
|
||
) error {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
if h.tm.GetTranscodeSession(sessionID) != previousLocal {
|
||
unlock()
|
||
return playback.ErrSessionSuperseded
|
||
}
|
||
|
||
_, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement)
|
||
if err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
if !published {
|
||
unlock()
|
||
return playback.ErrSessionSuperseded
|
||
}
|
||
if previousLocal != nil {
|
||
// A crash monitor can remove the predecessor while the remote successor is
|
||
// preparing. A false result is harmless: the new route is already complete,
|
||
// and CloseTranscodeSessionIf never touches a different local successor.
|
||
h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "")
|
||
}
|
||
unlock()
|
||
|
||
if previousRoute.NodeURL != "" {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyRemoteLastWriter(
|
||
sessionID string,
|
||
successorRoute playback.TranscodeRoute,
|
||
replacement playback.SessionReplacement,
|
||
) error {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
current, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
previousRoute := sessionTranscodeRoute(current)
|
||
previousLocal := h.tm.GetTranscodeSession(sessionID)
|
||
if _, err := h.sessionMgr.ApplyReplacement(sessionID, replacement); err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
if previousLocal != nil {
|
||
h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "")
|
||
}
|
||
unlock()
|
||
|
||
if previousRoute.NodeURL != "" && previousRoute != successorRoute {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyLocalReplacement(
|
||
ctx context.Context,
|
||
sessionID string,
|
||
previousLocal *playback.TranscodeSession,
|
||
previousRoute playback.TranscodeRoute,
|
||
opts playback.TranscodeOpts,
|
||
replacement func(*playback.TranscodeSession) playback.SessionReplacement,
|
||
) (*playback.TranscodeSession, error) {
|
||
if opts.OutputSubdir == "" {
|
||
return nil, errors.New("transactional local replacement requires a generation output directory")
|
||
}
|
||
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
if h.tm.GetTranscodeSession(sessionID) != previousLocal {
|
||
unlock()
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
current, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
unlock()
|
||
return nil, err
|
||
}
|
||
if sessionTranscodeRoute(current) != previousRoute {
|
||
unlock()
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
|
||
successor, err := h.startLocalPlaybackTransport(ctx, opts)
|
||
if err != nil {
|
||
unlock()
|
||
return nil, err
|
||
}
|
||
if _, err := successor.WaitForManifest(8 * time.Second); err != nil {
|
||
_ = successor.Close()
|
||
unlock()
|
||
return nil, fmt.Errorf("local successor not ready: %w", err)
|
||
}
|
||
|
||
rollback, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement(successor))
|
||
if err != nil || !published {
|
||
_ = successor.Close()
|
||
unlock()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
if !h.tm.SwapTranscodeSessionIf(sessionID, previousLocal, successor) {
|
||
rollbackErr := h.sessionMgr.RollbackReplacement(sessionID, rollback)
|
||
_ = successor.Close()
|
||
unlock()
|
||
return nil, errors.Join(playback.ErrSessionSuperseded, rollbackErr)
|
||
}
|
||
unlock()
|
||
|
||
if previousLocal != nil {
|
||
_ = previousLocal.Close()
|
||
}
|
||
if previousRoute.NodeURL != "" {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return successor, nil
|
||
}
|
||
|
||
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,
|
||
fallbackID int,
|
||
) (*models.MediaFile, error) {
|
||
if h.fileResolver == nil {
|
||
return nil, fmt.Errorf("file resolver not configured")
|
||
}
|
||
if preferredID > 0 {
|
||
file, err := h.fileResolver.GetByID(ctx, preferredID)
|
||
if err == nil && file != nil {
|
||
return file, nil
|
||
}
|
||
if err != nil && (fallbackID == 0 || fallbackID == preferredID) {
|
||
return nil, err
|
||
}
|
||
}
|
||
if fallbackID > 0 && fallbackID != preferredID {
|
||
return h.fileResolver.GetByID(ctx, fallbackID)
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func sourceVideoCodec(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if len(file.VideoTracks) > 0 && file.VideoTracks[0].Codec != "" {
|
||
return file.VideoTracks[0].Codec
|
||
}
|
||
return file.CodecVideo
|
||
}
|
||
|
||
func sourceAudioCodec(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if len(file.AudioTracks) > 0 && file.AudioTracks[0].Codec != "" {
|
||
return file.AudioTracks[0].Codec
|
||
}
|
||
return file.CodecAudio
|
||
}
|
||
|
||
func directPlayAudioTrackIndex(file *models.MediaFile) int {
|
||
if file == nil || len(file.AudioTracks) == 0 {
|
||
return 0
|
||
}
|
||
for i, track := range file.AudioTracks {
|
||
if track.Default {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func clientSupportsAudioCodec(req startPlaybackRequest, codec string) bool {
|
||
if codec == "" {
|
||
return true
|
||
}
|
||
if len(req.CodecsAudio) == 0 {
|
||
return playback.BrowserSupportsAudioCodec(codec)
|
||
}
|
||
for _, supported := range req.CodecsAudio {
|
||
if strings.EqualFold(supported, codec) {
|
||
return true
|
||
}
|
||
}
|
||
if req.AudioPassthrough != nil {
|
||
for _, supported := range req.AudioPassthrough.PassthroughCodecs {
|
||
if strings.EqualFold(supported, codec) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func adjustPlaybackForSelectedAudio(
|
||
file *models.MediaFile,
|
||
req startPlaybackRequest,
|
||
method playback.PlayMethod,
|
||
transcodeAudio bool,
|
||
audioTrackIndex int,
|
||
preserveDirectAudioSelection bool,
|
||
) (playback.PlayMethod, bool) {
|
||
if file == nil || len(file.AudioTracks) == 0 || audioTrackIndex < 0 || audioTrackIndex >= len(file.AudioTracks) {
|
||
return method, transcodeAudio
|
||
}
|
||
|
||
selectedTrack := file.AudioTracks[audioTrackIndex]
|
||
audioSupported := clientSupportsAudioCodec(req, selectedTrack.Codec)
|
||
|
||
switch method {
|
||
case playback.PlayDirect:
|
||
if preserveDirectAudioSelection {
|
||
return playback.PlayDirect, false
|
||
}
|
||
// Direct play cannot force the browser onto a non-default audio stream.
|
||
// Promote to remux so ffmpeg can map the selected track explicitly.
|
||
if audioTrackIndex != directPlayAudioTrackIndex(file) {
|
||
return playback.PlayRemux, !audioSupported
|
||
}
|
||
if !audioSupported {
|
||
return playback.PlayRemux, true
|
||
}
|
||
return method, false
|
||
case playback.PlayRemux:
|
||
return method, !audioSupported
|
||
default:
|
||
return method, transcodeAudio
|
||
}
|
||
}
|
||
|
||
func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
|
||
if file == nil || len(file.AudioTracks) == 0 {
|
||
return 0
|
||
}
|
||
if audioTrackIndex >= 0 && audioTrackIndex < len(file.AudioTracks) {
|
||
return audioTrackIndex
|
||
}
|
||
return directPlayAudioTrackIndex(file)
|
||
}
|
||
|
||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
|
||
settings := playback.AdminSettings{
|
||
TranscodeEnabled: transcodeEnabled,
|
||
}
|
||
if repo != nil {
|
||
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
|
||
settings.Allow4KTranscode = true
|
||
}
|
||
}
|
||
return settings
|
||
}
|
||
|
||
func resolvePlaybackMethodForFile(
|
||
file *models.MediaFile,
|
||
req startPlaybackRequest,
|
||
audioTrackIndex int,
|
||
adminSettings playback.AdminSettings,
|
||
) (playback.PlayMethod, bool) {
|
||
if file == nil {
|
||
return "", false
|
||
}
|
||
|
||
caps := playback.ClientCapabilities{
|
||
CodecsVideo: req.CodecsVideo,
|
||
CodecsAudio: req.CodecsAudio,
|
||
Containers: req.Containers,
|
||
MaxResolution: req.MaxResolution,
|
||
HDR: req.HDR,
|
||
}
|
||
if req.AudioPassthrough != nil {
|
||
caps.AudioPassthroughCodecs = req.AudioPassthrough.PassthroughCodecs
|
||
}
|
||
decision := playback.Resolve(file, caps, adminSettings)
|
||
return adjustPlaybackForSelectedAudio(file, req, decision.Method, decision.TranscodeAudio, audioTrackIndex, false)
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
|
||
ctx context.Context,
|
||
req startPlaybackRequest,
|
||
requestedFile *models.MediaFile,
|
||
audioTrackIndex int,
|
||
) (*models.MediaFile, playback.PlayMethod, bool, int) {
|
||
if requestedFile == nil {
|
||
return requestedFile, "", false, 0
|
||
}
|
||
|
||
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
|
||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
|
||
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
|
||
|
||
if requestedFile.Resolution == "2160p" &&
|
||
method == playback.PlayTranscode &&
|
||
!adminSettings.Allow4KTranscode &&
|
||
h.FileVersionFetcher != nil {
|
||
alt, err := h.findAlternateFile(ctx, requestedFile)
|
||
if err == nil && alt != nil {
|
||
effectiveFile := h.ensurePlaybackProbe(ctx, alt)
|
||
effectiveAudioTrackIndex := playback.MatchAudioTrackAcrossVersions(
|
||
requestedFile.AudioTracks,
|
||
effectiveFile.AudioTracks,
|
||
audioTrackIndex,
|
||
)
|
||
if effectiveAudioTrackIndex != audioTrackIndex {
|
||
slog.InfoContext(ctx, "remapped audio track for alternate file",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", effectiveFile.ID,
|
||
"requested_audio_track_index", audioTrackIndex,
|
||
"effective_audio_track_index", effectiveAudioTrackIndex,
|
||
)
|
||
}
|
||
audioTrackIndex = effectiveAudioTrackIndex
|
||
method, transcodeAudio = resolvePlaybackMethodForFile(effectiveFile, req, audioTrackIndex, adminSettings)
|
||
return effectiveFile, method, transcodeAudio, audioTrackIndex
|
||
}
|
||
}
|
||
|
||
return requestedFile, method, transcodeAudio, audioTrackIndex
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveSeriesID(ctx context.Context, file *models.MediaFile) string {
|
||
if file.EpisodeID == "" || h.EpisodeLookup == nil {
|
||
return ""
|
||
}
|
||
ep, err := h.EpisodeLookup.GetByID(ctx, file.EpisodeID)
|
||
if err != nil || ep == nil {
|
||
return ""
|
||
}
|
||
return ep.SeriesID
|
||
}
|
||
|
||
// resolveOriginalLanguage fetches the original language for a media file's content item.
|
||
// For episodes, it looks up the parent series. Returns empty string if unavailable.
|
||
func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *models.MediaFile) string {
|
||
if h.OriginalLangLookup == nil {
|
||
return ""
|
||
}
|
||
contentID := file.ContentID
|
||
if file.EpisodeID != "" {
|
||
contentID = h.resolveSeriesID(ctx, file)
|
||
}
|
||
if contentID == "" {
|
||
return ""
|
||
}
|
||
lang, err := h.OriginalLangLookup.GetOriginalLanguage(ctx, contentID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return lang
|
||
}
|
||
|
||
// resolvedProfileAudioLanguage returns the effective playback.audio_language
|
||
// for the profile with no content context, resolved through the settings
|
||
// contract — the canonical replacement for reading the legacy
|
||
// user_profiles.language column, matching catalog's detail resolution. It may
|
||
// return playback.OriginalLanguageSentinel, which the caller resolves to a
|
||
// concrete language. Returns "" when nothing is stored: the contract default
|
||
// is null, "no preference".
|
||
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
|
||
if store == nil || profileID == "" {
|
||
return ""
|
||
}
|
||
contract, err := settingscontract.Load()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||
settingsresolve.Context{ProfileID: profileID},
|
||
[]string{settingskeys.PlaybackAudioLanguage}, nil)
|
||
if err != nil || len(resolved) == 0 {
|
||
return ""
|
||
}
|
||
var language string
|
||
if json.Unmarshal(resolved[0].Value, &language) != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(language)
|
||
}
|
||
|
||
func (h *PlaybackHandler) restoreSessionProgress(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
file *models.MediaFile,
|
||
) {
|
||
if h.StoreProvider == nil || session == nil || file == nil {
|
||
return
|
||
}
|
||
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||
return
|
||
}
|
||
|
||
progress, err := store.GetProgress(ctx, session.ProfileID, targetID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to load progress", "component", "api", "target", targetID, "error", err)
|
||
return
|
||
}
|
||
|
||
if progress == nil || progress.Completed || progress.PositionSeconds <= 0 {
|
||
return
|
||
}
|
||
|
||
if err := h.sessionMgr.UpdateProgress(session.ID, progress.PositionSeconds, false); err != nil {
|
||
slog.ErrorContext(ctx, "failed to restore progress", "component", "api", "session", session.ID, "error", err)
|
||
return
|
||
}
|
||
|
||
session.Position = progress.PositionSeconds
|
||
session.IsPaused = false
|
||
}
|
||
|
||
// --- Persistence helpers ---
|
||
|
||
// persistProgress saves the current playback position to the UserStore.
|
||
// It resolves the mediaFileID to a mediaItemID via the file resolver.
|
||
// Errors are logged but do not fail the HTTP request.
|
||
func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback.Session) {
|
||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||
return
|
||
}
|
||
if session == nil || session.DisableProgressPersistence {
|
||
return
|
||
}
|
||
// Position 0 carries no resume information (mirrors persistStopAndHistory
|
||
// and the jellycompat report path). Progress is last-write-wins, so an
|
||
// early zero heartbeat — e.g. before a client finishes seeking to its
|
||
// resume point — must not wipe the stored resume position.
|
||
if session.Position <= 0 {
|
||
return
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
targetID := playbackProgressTarget(file)
|
||
if err != nil || targetID == "" {
|
||
return // file not found or not yet matched to a media item
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||
return
|
||
}
|
||
|
||
duration := float64(file.Duration)
|
||
if err := store.UpdateProgress(ctx, session.ProfileID, targetID, session.Position, duration, h.playbackThresholds(ctx)); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist progress", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||
}
|
||
|
||
if err := store.UpdateProgressHints(ctx, session.ProfileID, targetID, userstore.VersionHints{
|
||
FileID: file.ID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
EditionKey: file.EditionKey,
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist version hints", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
// persistStopAndHistory saves the final position and adds a watch history entry
|
||
// when a playback session is stopped. Errors are logged but do not fail the
|
||
// HTTP request.
|
||
func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *playback.Session) watchstate.PlaybackStopResult {
|
||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
targetID := playbackProgressTarget(file)
|
||
if err != nil || targetID == "" {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
|
||
duration := float64(file.Duration)
|
||
thresholds := h.playbackThresholds(ctx)
|
||
watchSvc := watchstate.NewService(h.StoreProvider).
|
||
WithStableIdentityResolver(h.StableIdentityResolver).
|
||
WithCompletionObserver(h.CompletionObserver)
|
||
stoppedAt := time.Now().UTC()
|
||
result, err := watchSvc.RecordPlaybackStop(ctx, session.UserID, session.ProfileID, targetID, duration, session.Position, stoppedAt, userstore.VersionHints{
|
||
FileID: file.ID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
EditionKey: file.EditionKey,
|
||
}, thresholds)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist playback stop", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (h *PlaybackHandler) scrobbleEventForSession(ctx context.Context, session *playback.Session, mediaItemID string, duration, position float64) watchsync.ScrobbleEvent {
|
||
event := watchsync.ScrobbleEvent{
|
||
PlaybackSessionID: session.ID,
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
MediaItemID: mediaItemID,
|
||
PositionSeconds: position,
|
||
DurationSeconds: duration,
|
||
OccurredAt: time.Now().UTC(),
|
||
}
|
||
return watchsync.ResolveScrobbleIdentity(ctx, h.StableIdentityResolver, event)
|
||
}
|
||
|
||
func (h *PlaybackHandler) scrobbleEventForStoppedSession(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
stopResult watchstate.PlaybackStopResult,
|
||
) (watchsync.ScrobbleEvent, bool) {
|
||
if session == nil || session.DisableProgressPersistence {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
|
||
mediaItemID := stopResult.MediaItemID
|
||
duration := stopResult.DurationSeconds
|
||
position := stopResult.FinalPositionSeconds
|
||
if mediaItemID == "" {
|
||
if h.fileResolver == nil {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
if err != nil || file == nil {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
mediaItemID = playbackProgressTarget(file)
|
||
if mediaItemID == "" {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
duration = float64(file.Duration)
|
||
position = session.Position
|
||
}
|
||
|
||
event := h.scrobbleEventForSession(ctx, session, mediaItemID, duration, position)
|
||
event.HistoryID = stopResult.HistoryID
|
||
event.Completed = stopResult.Completed
|
||
return event, true
|
||
}
|
||
|
||
func (h *PlaybackHandler) buildAdminHistoryEntry(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
) (*AdminPlaybackHistoryEntry, error) {
|
||
if h.AdminStore == nil || h.fileResolver == nil || session == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("loading media file: %w", err)
|
||
}
|
||
|
||
targetID := playbackProgressTarget(file)
|
||
profileName := session.ProfileID
|
||
if h.StoreProvider != nil {
|
||
store, storeErr := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if storeErr != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store for admin history", "component", "api", "session", session.ID, "error", storeErr)
|
||
} else if store != nil {
|
||
profile, profileErr := store.GetProfile(ctx, session.ProfileID)
|
||
if profileErr != nil {
|
||
slog.ErrorContext(ctx, "failed to load profile for admin history", "component", "api", "session", session.ID, "error", profileErr)
|
||
} else if profile != nil && strings.TrimSpace(profile.Name) != "" {
|
||
profileName = profile.Name
|
||
}
|
||
}
|
||
}
|
||
|
||
var durationPtr *float64
|
||
completed := false
|
||
if file != nil {
|
||
duration := float64(file.Duration)
|
||
durationPtr = &duration
|
||
if duration > 0 && session.Position/duration > userstore.WatchedFraction(h.playbackThresholds(ctx).WatchedPct) {
|
||
completed = true
|
||
}
|
||
}
|
||
|
||
entry := &AdminPlaybackHistoryEntry{
|
||
SessionID: session.ID,
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
ProfileName: profileName,
|
||
MediaItemID: targetID,
|
||
MediaFileID: requestedMediaFileID(session),
|
||
PlayMethod: string(semanticPlayMethod(session)),
|
||
StartedAt: session.StartedAt.UTC().Format(time.RFC3339Nano),
|
||
EndedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||
WatchedSeconds: session.Position,
|
||
DurationSeconds: durationPtr,
|
||
Completed: completed,
|
||
ClientIP: clientip.FromContext(ctx),
|
||
}
|
||
return entry, nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) {
|
||
if h.SessionSyncer == nil {
|
||
return
|
||
}
|
||
if err := h.SessionSyncer.SyncNow(ctx); err != nil {
|
||
slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) touchSessionActivity(sessionID string) {
|
||
if h == nil || sessionID == "" {
|
||
return
|
||
}
|
||
if err := h.sessionMgr.TouchActivity(sessionID); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
|
||
slog.Warn("failed to refresh playback activity", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *playback.Session, syncNow bool, syncReason string, userInitiated bool) {
|
||
if h == nil || session == nil || session.ID == "" {
|
||
return
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
stopResult := h.persistStopAndHistory(ctx, session)
|
||
if h.WatchScrobbler != nil {
|
||
if event, ok := h.scrobbleEventForStoppedSession(ctx, session, stopResult); ok && (userInitiated || stopResult.Completed) {
|
||
if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider stop scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
} else if ok {
|
||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider pause scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
if entry, buildErr := h.buildAdminHistoryEntry(ctx, session); buildErr != nil {
|
||
slog.ErrorContext(ctx, "failed to build admin history", "component", "api", "session", session.ID, "error", buildErr)
|
||
} else if entry != nil && h.AdminStore != nil {
|
||
if err := h.AdminStore.RecordHistory(ctx, *entry); err != nil {
|
||
slog.ErrorContext(ctx, "failed to record admin history", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
if h.AdminStore != nil {
|
||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
h.closeTranscodeForSession(session)
|
||
if syncNow {
|
||
h.syncSessionsNow(ctx, syncReason)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *playback.Session, syncNow bool, syncReason string) {
|
||
if h == nil || session == nil || session.ID == "" {
|
||
return
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
if h.WatchScrobbler != nil && h.fileResolver != nil {
|
||
if file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID); err == nil && file != nil {
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(ctx, session, targetID, float64(file.Duration), session.Position)
|
||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider abort scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if h.AdminStore != nil {
|
||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
// Abort is a connection drop / non-terminal teardown — keep the recipe card
|
||
// so the client can reconstruct on reconnect.
|
||
h.closeTranscodeForSession(session)
|
||
if syncNow {
|
||
h.syncSessionsNow(ctx, syncReason)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) handleExpiredSession(session *playback.Session) {
|
||
if h == nil || session == nil {
|
||
return
|
||
}
|
||
sessionCopy := *session
|
||
go func() {
|
||
slog.Info("expired inactive playback session", "session", sessionCopy.ID, "playback_session_id", sessionCopy.ID)
|
||
// Expiry is a liveness reap, not a user stop — keep the recipe card so a
|
||
// resume reconstructs under the same id (the card's own TTL reaps it if
|
||
// the session is truly abandoned).
|
||
h.finalizeSessionStop(context.Background(), &sessionCopy, false, "", false)
|
||
}()
|
||
}
|
||
|
||
func playbackProgressTarget(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if file.EpisodeID != "" {
|
||
return file.EpisodeID
|
||
}
|
||
return file.ContentID
|
||
}
|
||
|
||
func (h *PlaybackHandler) persistSeriesPlaybackPreference(
|
||
ctx context.Context,
|
||
userID int,
|
||
profileID string,
|
||
file *models.MediaFile,
|
||
) {
|
||
if h.StoreProvider == nil || file == nil {
|
||
return
|
||
}
|
||
|
||
seriesID := h.resolveSeriesID(ctx, file)
|
||
if seriesID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to access user store for series playback preference", "component", "api", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
|
||
if err := store.SetSeriesPlaybackPreference(ctx, userstore.SeriesPlaybackPreference{
|
||
ProfileID: profileID,
|
||
SeriesID: seriesID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist series playback preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) persistAudioPreference(
|
||
ctx context.Context,
|
||
userID int,
|
||
profileID string,
|
||
file *models.MediaFile,
|
||
trackIndex int,
|
||
) {
|
||
if h.StoreProvider == nil || file == nil || trackIndex < 0 || trackIndex >= len(file.AudioTracks) {
|
||
return
|
||
}
|
||
|
||
seriesID := h.resolveSeriesID(ctx, file)
|
||
if seriesID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to access user store for audio preference", "component", "api", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
|
||
track := file.AudioTracks[trackIndex]
|
||
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
||
ProfileID: profileID,
|
||
SeriesID: seriesID,
|
||
AudioTrackIndex: trackIndex,
|
||
AudioLanguage: track.Language,
|
||
TrackSignature: playback.AudioTrackSignatureFromTrack(track),
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist audio preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||
}
|
||
}
|
||
|
||
// --- Handler methods ---
|
||
|
||
// 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")
|
||
return
|
||
}
|
||
|
||
var req startPlaybackRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
if req.FileID == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "File ID is required")
|
||
return
|
||
}
|
||
profileID := apimw.GetProfileID(r.Context())
|
||
if profileID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
|
||
return
|
||
}
|
||
if req.ProfileID != "" && req.ProfileID != profileID {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "profile_id must match X-Profile-Id")
|
||
return
|
||
}
|
||
file, err := h.loadAuthorizedFile(r, req.FileID)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
default:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||
}
|
||
return
|
||
}
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
|
||
// Determine audio track.
|
||
audioTrackIndex := 0
|
||
if req.AudioTrackIndex != nil && *req.AudioTrackIndex >= 0 {
|
||
audioTrackIndex = *req.AudioTrackIndex
|
||
} else if file != nil && len(file.AudioTracks) > 0 && h.StoreProvider != nil {
|
||
var seriesPref *playback.AudioTrackPreference
|
||
var preferredLang string
|
||
store, storeErr := h.StoreProvider.ForUser(r.Context(), userID)
|
||
if storeErr == nil {
|
||
seriesID := h.resolveSeriesID(r.Context(), file)
|
||
if seriesID != "" {
|
||
if ap, apErr := store.GetAudioPreference(r.Context(), profileID, seriesID); apErr == nil && ap != nil {
|
||
seriesPref = &playback.AudioTrackPreference{
|
||
AudioTrackIndex: ap.AudioTrackIndex,
|
||
AudioLanguage: ap.AudioLanguage,
|
||
TrackSignature: ap.TrackSignature,
|
||
}
|
||
}
|
||
}
|
||
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
|
||
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
|
||
}
|
||
preferredLang = resolvedProfileAudioLanguage(r.Context(), store, profileID)
|
||
|
||
// Resolve library override (if no series sticky pref exists).
|
||
var libraryAudioLang string
|
||
if seriesPref == nil {
|
||
if lp, lpErr := store.GetLibraryPlaybackPreference(r.Context(), profileID, file.MediaFolderID); lpErr == nil && lp != nil && lp.AudioLanguage != "" {
|
||
libraryAudioLang = lp.AudioLanguage
|
||
}
|
||
}
|
||
|
||
// Resolve "original" sentinel at each preference level.
|
||
needsOriginalLang := preferredLang == playback.OriginalLanguageSentinel ||
|
||
libraryAudioLang == playback.OriginalLanguageSentinel
|
||
if needsOriginalLang {
|
||
originalLang := h.resolveOriginalLanguage(r.Context(), file)
|
||
if preferredLang == playback.OriginalLanguageSentinel {
|
||
preferredLang = originalLang
|
||
}
|
||
if libraryAudioLang == playback.OriginalLanguageSentinel {
|
||
libraryAudioLang = originalLang
|
||
}
|
||
}
|
||
|
||
// Apply library language override (skip if resolved to empty).
|
||
if libraryAudioLang != "" {
|
||
preferredLang = libraryAudioLang
|
||
}
|
||
}
|
||
audioTrackIndex = playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)
|
||
}
|
||
|
||
requestedFile := file
|
||
effectiveFile := requestedFile
|
||
method := playback.PlayMethod(req.PlayMethod)
|
||
transcodeAudio := false
|
||
|
||
// If the client sent codec capabilities and no explicit play method,
|
||
// use the resolver to determine the best play strategy.
|
||
if method == "" && h.fileResolver != nil && len(req.CodecsVideo) > 0 {
|
||
effectiveFile, method, transcodeAudio, audioTrackIndex = h.resolveCapabilityPlaybackSelection(
|
||
r.Context(),
|
||
req,
|
||
requestedFile,
|
||
audioTrackIndex,
|
||
)
|
||
}
|
||
|
||
if method == "" {
|
||
method = playback.PlayDirect
|
||
}
|
||
audioTrackIndex = normalizeAudioTrackIndex(effectiveFile, audioTrackIndex)
|
||
preserveDirectAudioSelection := method == playback.PlayDirect &&
|
||
strings.EqualFold(req.PlayMethod, string(playback.PlayDirect)) &&
|
||
req.PreserveDirectAudioSelection
|
||
method, transcodeAudio = adjustPlaybackForSelectedAudio(
|
||
effectiveFile,
|
||
req,
|
||
method,
|
||
transcodeAudio,
|
||
audioTrackIndex,
|
||
preserveDirectAudioSelection,
|
||
)
|
||
if requestedFile != nil && effectiveFile != nil && requestedFile.ID != effectiveFile.ID {
|
||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||
slog.WarnContext(r.Context(), "requested playback file preflight failed; continuing with alternate file", "component", "api",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", effectiveFile.ID,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil {
|
||
writePlaybackFilePreflightError(w, err)
|
||
return
|
||
}
|
||
|
||
clientInfo := playbackClientInfoFromRequest(r)
|
||
sessionCtx := playback.WithClientInfo(r.Context(), clientInfo)
|
||
var session *playback.Session
|
||
if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok {
|
||
session, err = starter.StartSessionWithFilesContext(
|
||
sessionCtx,
|
||
userID,
|
||
profileID,
|
||
effectiveFile.ID,
|
||
req.FileID,
|
||
method,
|
||
transcodeAudio,
|
||
)
|
||
} else {
|
||
session, err = h.sessionMgr.StartSessionWithFiles(
|
||
userID,
|
||
profileID,
|
||
effectiveFile.ID,
|
||
req.FileID,
|
||
method,
|
||
transcodeAudio,
|
||
)
|
||
}
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrTooManyStreams) {
|
||
writeError(w, http.StatusTooManyRequests, "too_many_streams", "Too many concurrent streams")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrTooManyTranscodes) {
|
||
writeError(w, http.StatusTooManyRequests, "too_many_transcodes", "Too many concurrent transcodes")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrPlaybackNotAllowed) {
|
||
writeError(w, http.StatusForbidden, "playback_not_allowed", "Playback denied by server policy")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start playback session")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, session.ID)
|
||
if req.DisableProgressPersistence {
|
||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to disable progress persistence", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
session.DisableProgressPersistence = true
|
||
}
|
||
}
|
||
|
||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set audio track", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
targetAudioCodec := ""
|
||
if session.TranscodeAudio {
|
||
targetAudioCodec = "aac"
|
||
}
|
||
streamBitrateKbps := 0
|
||
if effectiveFile != nil {
|
||
streamBitrateKbps = effectiveFile.Bitrate
|
||
}
|
||
if err := h.sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{
|
||
PlayMethod: session.PlayMethod,
|
||
BasePlayMethod: session.BasePlayMethod,
|
||
AudioTrackIndex: audioTrackIndex,
|
||
TranscodeAudio: session.TranscodeAudio,
|
||
ClientIP: clientip.FromContext(r.Context()),
|
||
ClientName: clientInfo.Name,
|
||
ClientVersion: clientInfo.Version,
|
||
ClientUserAgent: clientInfo.UserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetAudioCodec: targetAudioCodec,
|
||
}); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set stream state", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
session.AudioTrackIndex = audioTrackIndex
|
||
session.ClientIP = clientip.FromContext(r.Context())
|
||
session.StreamBitrateKbps = streamBitrateKbps
|
||
session.TargetAudioCodec = targetAudioCodec
|
||
h.persistSeriesPlaybackPreference(r.Context(), userID, profileID, effectiveFile)
|
||
|
||
if req.StartPosition != nil {
|
||
if err := h.sessionMgr.UpdateProgress(session.ID, *req.StartPosition, false); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set explicit start position", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
session.Position = *req.StartPosition
|
||
session.IsPaused = false
|
||
}
|
||
} else {
|
||
h.restoreSessionProgress(r.Context(), session, file)
|
||
}
|
||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||
targetID := playbackProgressTarget(effectiveFile)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||
if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider start scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
if h.ChapterThumbnailQueuer != nil && effectiveFile != nil {
|
||
slog.InfoContext(r.Context(),
|
||
"queueing chapter thumbnails", "component", "api",
|
||
"source",
|
||
"playback_start",
|
||
"content_id",
|
||
effectiveFile.ContentID,
|
||
"file_id",
|
||
effectiveFile.ID,
|
||
"target_seconds",
|
||
session.Position,
|
||
)
|
||
h.ChapterThumbnailQueuer.QueuePriorityFileAtPosition(
|
||
r.Context(),
|
||
effectiveFile.ID,
|
||
session.Position,
|
||
)
|
||
}
|
||
h.maybeQueueLazyPlaybackMarkers(r.Context(), session, effectiveFile)
|
||
|
||
// Direct-play and remux sessions reconstruct from the identity stream token
|
||
// carried on their serve URL (see playbackStreamURL); there is no server-side
|
||
// card to persist. Transcode sessions receive their full-recipe token from
|
||
// HandleStartTranscode.
|
||
resp := h.toPlaybackSessionResponse(session)
|
||
resp.DurationSeconds = fileDurationSeconds(effectiveFile)
|
||
resp.PlaybackInfo = buildPlaybackInfo(session, effectiveFile)
|
||
|
||
var downloadedSubs []subtitles.DownloadedSubtitle
|
||
if h.SubtitleRepo != nil && effectiveFile != nil {
|
||
downloadedSubs, _ = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), effectiveFile.ID)
|
||
}
|
||
resp.SubtitleURLs = buildSubtitleURLs(
|
||
session.ID,
|
||
effectiveFile,
|
||
downloadedSubs,
|
||
req.SupportsBitmapSubtitleBurnIn,
|
||
)
|
||
|
||
// If stream nodes are available, generate proxy-based stream URLs.
|
||
// Remux and transcode both use HLS via a transcode node, so the planner
|
||
// picks the transcode node and its group's proxy together.
|
||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
|
||
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
|
||
proxyNode := plan.ProxyNode
|
||
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||
tokenClaims := streamtoken.Claims{
|
||
SessionID: session.ID,
|
||
PlayMethod: string(session.PlayMethod),
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
MediaFileID: session.MediaFileID,
|
||
}
|
||
|
||
// Resolve media path if possible.
|
||
if effectiveFile != nil {
|
||
tokenClaims.MediaPath = effectiveFile.FilePath
|
||
tokenClaims.DVProfile = effectiveFile.PrimaryDVProfile()
|
||
}
|
||
|
||
tokenClaims.TranscodeAudio = session.TranscodeAudio
|
||
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
|
||
|
||
if plan.TranscodeNode != nil {
|
||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
|
||
}
|
||
|
||
token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, playback.MaxTokenTTL)
|
||
if signErr == nil {
|
||
switch session.PlayMethod {
|
||
case playback.PlayDirect:
|
||
resp.StreamURL = proxyNode.URL + "/stream/direct/" + token
|
||
case playback.PlayRemux, playback.PlayTranscode:
|
||
resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
|
||
// Update subtitle URLs to use proxy for embedded subs only.
|
||
// External and downloaded subs stay on the API server since
|
||
// the proxy doesn't have access to those files.
|
||
embeddedOffset := 0
|
||
if file != nil {
|
||
embeddedOffset = len(file.ExternalSubtitles)
|
||
}
|
||
for i := range resp.SubtitleURLs {
|
||
if resp.SubtitleURLs[i].Source == "embedded" {
|
||
// Pass the ffmpeg-relative subtitle stream index to the proxy.
|
||
embeddedIdx := resp.SubtitleURLs[i].Index - embeddedOffset
|
||
proxySubtitleURL := proxyNode.URL + "/stream/subtitles/" + token + "/" + strconv.Itoa(embeddedIdx)
|
||
resp.SubtitleURLs[i].URL = proxySubtitleURL + subtitleURLExt(resp.SubtitleURLs[i].Codec)
|
||
if resp.SubtitleURLs[i].FontBundleURL != "" {
|
||
resp.SubtitleURLs[i].FontBundleURL = proxySubtitleURL + "/fonts"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo {
|
||
if r == nil {
|
||
return playback.ClientInfo{}
|
||
}
|
||
return playback.ClientInfo{
|
||
Name: strings.TrimSpace(r.Header.Get("X-Silo-Client")),
|
||
Version: strings.TrimSpace(r.Header.Get("X-Silo-Client-Version")),
|
||
UserAgent: r.UserAgent(),
|
||
}
|
||
}
|
||
|
||
// subtitleURLExt returns the URL file extension for a subtitle codec.
|
||
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
|
||
// client-side rendering (JASSUB); PGS tracks get ".sup" for native clients
|
||
// capable of rendering bitmap sidecars; all other text formats get ".vtt".
|
||
func subtitleURLExt(codec string) string {
|
||
switch {
|
||
case playback.IsASS(codec):
|
||
return ".ass"
|
||
case playback.IsPGS(codec):
|
||
return ".sup"
|
||
}
|
||
return ".vtt"
|
||
}
|
||
|
||
func buildSubtitleURLs(
|
||
sessionID string,
|
||
file *models.MediaFile,
|
||
downloaded []subtitles.DownloadedSubtitle,
|
||
includeBurnInOnly bool,
|
||
) []subtitleURL {
|
||
if file == nil {
|
||
return nil
|
||
}
|
||
|
||
urls := make([]subtitleURL, 0, len(file.ExternalSubtitles)+len(file.SubtitleTracks)+len(downloaded))
|
||
|
||
for i, sub := range file.ExternalSubtitles {
|
||
urls = append(urls, subtitleURL{
|
||
Index: i,
|
||
MediaFileID: file.ID,
|
||
Language: sub.Language,
|
||
Codec: sub.Format,
|
||
Label: firstNonEmptyString(sub.Title, sub.EmbeddedTitle, filepath.Base(sub.Path), sub.Language),
|
||
Source: "external",
|
||
Forced: sub.Forced,
|
||
HearingImpaired: sub.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, i, sub.Format, file.ID),
|
||
})
|
||
}
|
||
|
||
embeddedOffset := len(file.ExternalSubtitles)
|
||
for i, track := range file.SubtitleTracks {
|
||
// PGS remains universally deliverable as a .sup sidecar. DVD/DVB
|
||
// bitmap tracks have no usable sidecar representation, so advertise
|
||
// them only to clients that explicitly declare server-side burn-in
|
||
// support. Older Apple/Android clients otherwise expose a text URL
|
||
// that ffmpeg cannot serve.
|
||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) && !includeBurnInOnly {
|
||
continue
|
||
}
|
||
urls = append(urls, subtitleURL{
|
||
Index: embeddedOffset + i,
|
||
MediaFileID: file.ID,
|
||
Language: track.Language,
|
||
Codec: track.Codec,
|
||
Label: firstNonEmptyString(track.Title, track.EmbeddedTitle, track.Language),
|
||
Source: "embedded",
|
||
Forced: track.Forced,
|
||
HearingImpaired: track.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, embeddedOffset+i, track.Codec, file.ID),
|
||
FontBundleURL: subtitleFontBundleURL(sessionID, embeddedOffset+i, track.Codec, file.ID),
|
||
})
|
||
}
|
||
|
||
downloadedOffset := embeddedOffset + len(file.SubtitleTracks)
|
||
for i, dl := range downloaded {
|
||
urls = append(urls, subtitleURL{
|
||
Index: downloadedOffset + i,
|
||
MediaFileID: file.ID,
|
||
Language: dl.Language,
|
||
Codec: string(dl.Format),
|
||
Label: dl.ReleaseName + " (" + dl.Provider + ")",
|
||
Source: "downloaded",
|
||
HearingImpaired: dl.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
|
||
})
|
||
}
|
||
|
||
return urls
|
||
}
|
||
|
||
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
|
||
}
|
||
|
||
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||
if !playback.IsASS(codec) {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("/stream/%s/subtitles/%d/fonts?file_id=%d", sessionID, trackIndex, fileID)
|
||
}
|
||
|
||
func firstNonEmptyString(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// HandleUpdateProgress handles POST /playback/{session_id}/progress.
|
||
func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
wasPaused := session.IsPaused
|
||
|
||
var req progressRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
err = h.sessionMgr.UpdateProgress(sessionID, req.Position, req.IsPaused)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress")
|
||
return
|
||
}
|
||
h.syncSessionsNow(r.Context(), "progress")
|
||
|
||
// Persist progress to UserStore (best-effort).
|
||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||
h.persistProgress(r.Context(), sess)
|
||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(r.Context(), sess, targetID, float64(file.Duration), sess.Position)
|
||
if sess.IsPaused {
|
||
if err := h.WatchScrobbler.ScrobblePause(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider pause scrobble", "component", "api", "session", sessionID, "error", err)
|
||
}
|
||
} else if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider resume scrobble", "component", "api", "session", sessionID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// HandleStopPlayback handles DELETE /playback/{session_id}.
|
||
func (h *PlaybackHandler) HandleStopPlayback(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
err = h.stopPlaybackSession(r.Context(), session, true)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to stop playback session")
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// HandleChangeAudioTrack handles PATCH /playback/{session_id}/audio.
|
||
func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
previousRoute := sessionTranscodeRoute(session)
|
||
|
||
var req changeAudioRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
// Load file to validate track index.
|
||
if h.fileResolver == nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "File resolver not configured")
|
||
return
|
||
}
|
||
file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID)
|
||
if err != nil || file == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
if req.AudioTrackIndex < 0 || req.AudioTrackIndex >= len(file.AudioTracks) {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Audio track index out of range")
|
||
return
|
||
}
|
||
|
||
baseMethod := semanticPlayMethod(session)
|
||
newMethod := baseMethod
|
||
transcodeAudio := session.TranscodeAudio
|
||
|
||
newTrack := file.AudioTracks[req.AudioTrackIndex]
|
||
audioCodecNeedsTranscode := !playback.BrowserSupportsAudioCodec(newTrack.Codec)
|
||
|
||
if baseMethod == playback.PlayDirect {
|
||
newMethod = playback.PlayRemux
|
||
transcodeAudio = audioCodecNeedsTranscode
|
||
} else if baseMethod == playback.PlayRemux {
|
||
transcodeAudio = audioCodecNeedsTranscode
|
||
} else if baseMethod == playback.PlayTranscode {
|
||
transcodeAudio = true
|
||
}
|
||
|
||
requiresVideoTranscode := baseMethod == playback.PlayTranscode ||
|
||
(session.PlayMethod == playback.PlayTranscode &&
|
||
!strings.EqualFold(session.TargetVideoCodec, "copy"))
|
||
if (requiresVideoTranscode || transcodeAudio) &&
|
||
!h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||
return
|
||
}
|
||
|
||
targetResolution := ""
|
||
targetVideoCodec := ""
|
||
targetAudioCodec := ""
|
||
targetBitrateKbps := 0
|
||
streamBitrateKbps := session.StreamBitrateKbps
|
||
if session.PlayMethod == playback.PlayTranscode {
|
||
targetResolution = session.TargetResolution
|
||
targetVideoCodec = session.TargetVideoCodec
|
||
targetBitrateKbps = session.TargetBitrateKbps
|
||
if newMethod == playback.PlayTranscode || transcodeAudio {
|
||
targetAudioCodec = "aac"
|
||
} else {
|
||
targetAudioCodec = "copy"
|
||
}
|
||
} else if transcodeAudio {
|
||
targetAudioCodec = "aac"
|
||
}
|
||
|
||
// A legacy copy-video restart needs a fresh keyframe origin for the new
|
||
// position. Resolve it before mutating the durable session or stopping the
|
||
// current transport so a probe failure leaves the active stream intact.
|
||
restartSegmentDuration := session.SegmentDuration
|
||
if restartSegmentDuration <= 0 {
|
||
restartSegmentDuration = playback.DefaultSegmentDuration
|
||
}
|
||
if ts := h.tm.GetTranscodeSession(sessionID); ts != nil {
|
||
if liveDuration := ts.Opts().SegmentDuration; liveDuration > 0 {
|
||
restartSegmentDuration = liveDuration
|
||
}
|
||
}
|
||
restartSeekSeconds := alignedSeekSeconds(req.Position, restartSegmentDuration, targetVideoCodec)
|
||
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
|
||
restartStreamOriginSeconds := 0.0
|
||
restartCopyAnchorResolved := false
|
||
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
|
||
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
|
||
if legacyCopyRestart {
|
||
restartCopyAnchorResolved = true
|
||
if req.Position > 0 {
|
||
anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor(
|
||
r.Context(),
|
||
h.playbackConfig().FFmpegPath,
|
||
file.FilePath,
|
||
req.Position,
|
||
restartSegmentDuration,
|
||
)
|
||
if anchorErr != nil {
|
||
slog.ErrorContext(r.Context(), "failed to resolve copy-video audio-switch seek anchor", "component", "api",
|
||
"playback_session_id", sessionID,
|
||
"requested_seek_seconds", req.Position,
|
||
"error", anchorErr,
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position")
|
||
return
|
||
}
|
||
restartStreamOriginSeconds = anchor
|
||
restartStartSegment = anchorSegment
|
||
}
|
||
}
|
||
var deferredRemoteCopyPlan *nodepool.Plan
|
||
if legacyCopyRestart && strings.TrimSpace(session.TranscodeNodeURL) != "" &&
|
||
h.NodePlanner != nil && h.JWTSecret != "" {
|
||
estKbps := targetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, true, estKbps)
|
||
if plan.ProxyNode != nil && plan.TranscodeNode != nil {
|
||
deferredRemoteCopyPlan = &plan
|
||
}
|
||
}
|
||
slog.InfoContext(r.Context(), "audio switch computed playback state", "component", "api",
|
||
"playback_session_id", sessionID,
|
||
"previous_base_play_method", baseMethod,
|
||
"new_base_play_method", newMethod,
|
||
"transport_play_method", session.PlayMethod,
|
||
"audio_track_index", req.AudioTrackIndex,
|
||
"audio_codec", newTrack.Codec,
|
||
"transcode_audio", transcodeAudio,
|
||
)
|
||
updatedSession := *session
|
||
updatedSession.AudioTrackIndex = req.AudioTrackIndex
|
||
updatedSession.BasePlayMethod = newMethod
|
||
if session.PlayMethod != playback.PlayTranscode || newMethod == playback.PlayTranscode {
|
||
updatedSession.PlayMethod = newMethod
|
||
}
|
||
updatedSession.TranscodeAudio = transcodeAudio
|
||
updatedSession.TargetResolution = targetResolution
|
||
updatedSession.TargetVideoCodec = targetVideoCodec
|
||
updatedSession.TargetAudioCodec = targetAudioCodec
|
||
updatedSession.TargetBitrateKbps = targetBitrateKbps
|
||
updatedSession.SegmentDuration = restartSegmentDuration
|
||
|
||
audioSwitchReplacement := func(route playback.TranscodeRoute) playback.SessionReplacement {
|
||
return playback.SessionReplacement{
|
||
EffectiveMediaFileID: session.MediaFileID,
|
||
StreamState: playback.SessionStreamState{
|
||
PlayMethod: updatedSession.PlayMethod,
|
||
BasePlayMethod: newMethod,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
TranscodeAudio: transcodeAudio,
|
||
RemuxDVMode: session.RemuxDVMode,
|
||
ClientIP: session.ClientIP,
|
||
ClientName: session.ClientName,
|
||
ClientVersion: session.ClientVersion,
|
||
ClientUserAgent: session.ClientUserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetResolution: targetResolution,
|
||
TargetVideoCodec: targetVideoCodec,
|
||
TargetAudioCodec: targetAudioCodec,
|
||
TargetBitrateKbps: targetBitrateKbps,
|
||
TranscodeHWAccel: updatedSession.TranscodeHWAccel,
|
||
TranscodeNodeURL: route.NodeURL,
|
||
TranscodeTransportID: route.TransportID,
|
||
TranscodeRouteSet: true,
|
||
SubtitleTrackIndex: session.SubtitleTrackIndex,
|
||
SubtitleBurnIn: session.SubtitleBurnIn,
|
||
SegmentDuration: restartSegmentDuration,
|
||
},
|
||
}
|
||
}
|
||
audioStatePublished := false
|
||
publishAudioSwitch := func(route playback.TranscodeRoute) error {
|
||
if _, err := h.sessionMgr.ApplyReplacement(sessionID, audioSwitchReplacement(route)); err != nil {
|
||
return err
|
||
}
|
||
audioStatePublished = true
|
||
updatedSession.TranscodeNodeURL = route.NodeURL
|
||
updatedSession.TranscodeTransportID = route.TransportID
|
||
return nil
|
||
}
|
||
|
||
// Local audio switches stage a successor in a generation-scoped directory.
|
||
// The predecessor keeps serving until the successor has a manifest and the
|
||
// full session replacement has been published atomically.
|
||
if session.PlayMethod == playback.PlayTranscode && deferredRemoteCopyPlan == nil {
|
||
if previousLocal := h.tm.GetTranscodeSession(sessionID); previousLocal != nil {
|
||
opts := previousLocal.Opts()
|
||
outputSubdir := newLegacyTransportID(sessionID)
|
||
opts.OutputSubdir = outputSubdir
|
||
opts.OutputDir = filepath.Join(h.playbackConfig().TranscodeDir, outputSubdir)
|
||
opts.TranscodeTransportID = ""
|
||
opts.AudioTrackIndex = req.AudioTrackIndex
|
||
opts.SeekSeconds = restartSeekSeconds
|
||
opts.StartSegmentNumber = restartStartSegment
|
||
opts.StreamOriginSeconds = restartStreamOriginSeconds
|
||
opts.CopySeekAnchorResolved = restartCopyAnchorResolved
|
||
opts.FastStart = true
|
||
successor, restartErr := h.commitLegacyLocalReplacement(
|
||
context.WithoutCancel(r.Context()),
|
||
sessionID,
|
||
previousLocal,
|
||
previousRoute,
|
||
opts,
|
||
func(successor *playback.TranscodeSession) playback.SessionReplacement {
|
||
updatedSession.TranscodeHWAccel = successor.Opts().HWAccel
|
||
return audioSwitchReplacement(playback.TranscodeRoute{})
|
||
},
|
||
)
|
||
if restartErr != nil {
|
||
if errors.Is(restartErr, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "failed to prepare transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr)
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to restart transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
updatedSession.TranscodeNodeURL = ""
|
||
updatedSession.TranscodeTransportID = ""
|
||
successor.SetRestartHook(func(ctx context.Context) {
|
||
h.maybeStartThrottler(ctx, successor)
|
||
h.tm.MonitorLocalTranscodeExit(sessionID, successor)
|
||
})
|
||
h.maybeStartThrottler(r.Context(), successor)
|
||
h.tm.MonitorLocalTranscodeExit(sessionID, successor)
|
||
}
|
||
}
|
||
|
||
if session.PlayMethod != playback.PlayTranscode {
|
||
if err := publishAudioSwitch(playback.TranscodeRoute{}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track")
|
||
return
|
||
}
|
||
}
|
||
|
||
// The switched recipe travels in the freshly minted stream token on the new
|
||
// serve URL below, so a post-restart reconstruct resumes with the switched
|
||
// audio/method. For transcode the full-recipe manifest URL is rebuilt further
|
||
// down (proxy or local); for direct/remux the identity token on StreamURL
|
||
// carries the new audio selection.
|
||
if audioStatePublished {
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
}
|
||
|
||
// For a local transcode, playbackStreamURL returns the bare manifest URL
|
||
// without the full-recipe ?st= token, so a post-restart reconstruct would
|
||
// fall back to the stale pre-switch token. Rebuild the signed manifest URL
|
||
// from the live transcode opts, mirroring HandleStartTranscode. The proxy
|
||
// branch below overrides this when a node plan picks a proxy/transcode node.
|
||
streamURL := h.playbackStreamURL(&updatedSession)
|
||
if updatedSession.PlayMethod == playback.PlayTranscode {
|
||
if ts := h.tm.GetTranscodeSession(sessionID); ts != nil {
|
||
card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, updatedSession.TranscodeNodeURL, ts.Opts())
|
||
streamURL = appendStreamToken(
|
||
fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID),
|
||
h.signSessionToken(card),
|
||
)
|
||
}
|
||
}
|
||
|
||
resp := changeAudioResponse{
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
PlayMethod: string(newMethod),
|
||
StreamURL: streamURL,
|
||
SwitchMode: "reload",
|
||
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
|
||
}
|
||
|
||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
|
||
estKbps := updatedSession.TargetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
var plan nodepool.Plan
|
||
if deferredRemoteCopyPlan != nil {
|
||
plan = *deferredRemoteCopyPlan
|
||
} else {
|
||
plan = h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
|
||
}
|
||
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||
// Remote (offloaded) transcode: the API server owns no local
|
||
// TranscodeSession (the LOCAL restart block above was a no-op), so
|
||
// the node's ffmpeg is still serving the OLD audio track. POST a
|
||
// fresh /transcode/start with the new AudioTrackIndex. Encoded legacy
|
||
// streams retain the node's same-ID replacement behavior; copy-video
|
||
// streams prepare a distinct successor and retire the predecessor only
|
||
// after readiness and route publication. Then mint the proxy URL
|
||
// from a FULL recipe card so a later node restart reconstructs with
|
||
// the switched audio (the lean identity-only claims used for remux
|
||
// below omit the byte-affecting encode fields and would 404).
|
||
isOffloaded := strings.TrimSpace(session.TranscodeNodeURL) != ""
|
||
if needsTranscode && plan.TranscodeNode != nil && isOffloaded {
|
||
nodeURL := plan.TranscodeNode.URL
|
||
atomicLegacyReplacement := legacyCopyRestart
|
||
previousLocalTranscode := h.tm.GetTranscodeSession(sessionID)
|
||
|
||
// Restart from the FULL live recipe, not a partial re-derivation.
|
||
// An audio switch alters only audio selection — subtitle burn-in and
|
||
// the segment cadence must be preserved, or the node re-encodes a
|
||
// different byte stream (subtitles silently dropped, wrong cadence)
|
||
// and signs that altered recipe into the new token. The session
|
||
// retains these from the original start (finalizeTranscodeStart) or a
|
||
// post-restart reconstruct, so recover them here. Embed a concrete
|
||
// segment duration (not 0): the node's recipe token treats
|
||
// SegmentDuration<=0 as "incomplete" and would 404 on a node restart.
|
||
segmentDuration := restartSegmentDuration
|
||
subtitleTrackIndex := session.SubtitleTrackIndex
|
||
subtitleBurnIn := session.SubtitleBurnIn
|
||
subtitleCodec := ""
|
||
if subtitleBurnIn && subtitleTrackIndex >= 0 {
|
||
subtitleCodec = embeddedSubtitleCodec(file, subtitleTrackIndex)
|
||
}
|
||
// Derive the encode recipe the same way HandleStartTranscode
|
||
// does — from the durable session target fields plus the file —
|
||
// 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)
|
||
if atomicLegacyReplacement {
|
||
restartTransportID = newLegacyTransportID(sessionID)
|
||
}
|
||
nodeReq := transcodenode.TranscodeStartRequest{
|
||
SessionID: restartTransportID,
|
||
InputPath: file.FilePath,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
SeekSeconds: restartSeekSeconds,
|
||
StreamOriginSeconds: restartStreamOriginSeconds,
|
||
CopySeekAnchorResolved: restartCopyAnchorResolved,
|
||
StartSegmentNumber: restartStartSegment,
|
||
TargetResolution: updatedSession.TargetResolution,
|
||
TargetCodecVideo: updatedSession.TargetVideoCodec,
|
||
TargetCodecAudio: updatedSession.TargetAudioCodec,
|
||
TargetBitrateKbps: updatedSession.TargetBitrateKbps,
|
||
SegmentDuration: segmentDuration,
|
||
HWAccel: session.TranscodeHWAccel,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
SubtitleTrackIndex: subtitleTrackIndex,
|
||
SubtitleBurnIn: subtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
RequireReady: atomicLegacyReplacement,
|
||
}
|
||
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. Sources that
|
||
// fail the RPU probe are the exception — for them the filter
|
||
// rejects every packet, so re-adding it on an audio switch
|
||
// would hang a session that was playing a moment ago.
|
||
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") &&
|
||
playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
|
||
nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
|
||
}
|
||
|
||
startResp, status, startErr := h.startRemotePlaybackTransport(
|
||
context.WithoutCancel(r.Context()),
|
||
nodeURL,
|
||
nodeReq,
|
||
)
|
||
if startErr != nil {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", startErr)
|
||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||
return
|
||
}
|
||
if status != http.StatusAccepted {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", status)
|
||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||
return
|
||
}
|
||
effectiveHWAccel := effectiveRemoteHWAccel(startResp, nodeReq)
|
||
updatedSession.TranscodeHWAccel = effectiveHWAccel
|
||
successorTransportID := restartTransportID
|
||
if !atomicLegacyReplacement {
|
||
// Empty is the durable legacy representation for a node process
|
||
// running under the public playback session ID.
|
||
successorTransportID = session.TranscodeTransportID
|
||
}
|
||
successorRoute := playback.TranscodeRoute{NodeURL: nodeURL, TransportID: successorTransportID}
|
||
if atomicLegacyReplacement {
|
||
if routeErr := h.commitLegacyRemoteReplacement(
|
||
sessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
audioSwitchReplacement(successorRoute),
|
||
); routeErr != nil {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
if errors.Is(routeErr, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
if !h.transcodeRouteMatches(sessionID, nil, successorRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
} else {
|
||
if routeErr := h.commitLegacyRemoteLastWriter(
|
||
sessionID,
|
||
successorRoute,
|
||
audioSwitchReplacement(successorRoute),
|
||
); routeErr != nil {
|
||
slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
}
|
||
updatedSession.TranscodeNodeURL = successorRoute.NodeURL
|
||
updatedSession.TranscodeTransportID = successorRoute.TransportID
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
|
||
card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, nodeURL, playback.TranscodeOpts{
|
||
InputPath: nodeReq.InputPath,
|
||
SessionID: sessionID,
|
||
TranscodeTransportID: restartTransportID,
|
||
VideoBitstreamFilter: nodeReq.VideoBitstreamFilter,
|
||
SourceVideoCodec: nodeReq.SourceVideoCodec,
|
||
SeekSeconds: nodeReq.SeekSeconds,
|
||
StreamOriginSeconds: nodeReq.StreamOriginSeconds,
|
||
CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved,
|
||
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 {
|
||
// Remux, or a non-offloaded (locally served) transcode: no remote
|
||
// node ffmpeg to restart, so carry the new audio selection on the
|
||
// identity claims of the proxy serve URL, exactly as before. A
|
||
// local transcode reconstructs from the API server's own state, so
|
||
// the lean token is sufficient here.
|
||
tokenClaims := streamtoken.Claims{
|
||
SessionID: sessionID,
|
||
PlayMethod: string(updatedSession.PlayMethod),
|
||
MediaPath: file.FilePath,
|
||
TranscodeAudio: updatedSession.TranscodeAudio,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
DVProfile: file.PrimaryDVProfile(),
|
||
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
|
||
}
|
||
if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, playback.MaxTokenTTL); signErr == nil {
|
||
switch updatedSession.PlayMethod {
|
||
case playback.PlayRemux:
|
||
resp.StreamURL = proxyNode.URL + "/stream/remux/" + token
|
||
case playback.PlayTranscode:
|
||
resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !audioStatePublished {
|
||
if err := publishAudioSwitch(previousRoute); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track")
|
||
return
|
||
}
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
}
|
||
if legacyCopyRestart {
|
||
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
|
||
}
|
||
|
||
h.syncSessionsNow(r.Context(), "audio_change")
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*models.MediaFile, error) {
|
||
if h.fileResolver == nil || h.ItemAccess == nil {
|
||
return nil, fmt.Errorf("playback authorization dependencies not configured")
|
||
}
|
||
file, err := h.fileResolver.GetByID(r.Context(), fileID)
|
||
if err != nil {
|
||
return nil, mapMediaFileLookupError(err)
|
||
}
|
||
if file == nil || file.MissingSince != nil {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
filter := requestAccessFilter(r)
|
||
switch {
|
||
case file.EpisodeID != "":
|
||
if h.EpisodeLookup == nil {
|
||
return nil, fmt.Errorf("episode lookup not configured")
|
||
}
|
||
episode, err := h.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if episode == nil {
|
||
return nil, catalog.ErrEpisodeNotFound
|
||
}
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
case file.ContentID != "":
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
case file.ExtraID != "":
|
||
if h.ExtraLookup == nil {
|
||
return nil, fmt.Errorf("extra lookup not configured")
|
||
}
|
||
extra, err := h.ExtraLookup.GetByID(r.Context(), file.ExtraID)
|
||
if err != nil {
|
||
if errors.Is(err, catalog.ErrExtraNotFound) {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
if extra == nil {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), extra.ParentID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
default:
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
if !catalog.FileAllowedByAccess(file, filter) {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
return file, nil
|
||
}
|
||
|
||
// embeddedSubtitleCodec returns the probed codec of the embedded subtitle
|
||
// track at the given ffmpeg-relative subtitle ordinal (the same index the
|
||
// subtitles=si=N / [0:s:N] filters use), or "" when out of range.
|
||
func embeddedSubtitleCodec(file *models.MediaFile, ffmpegSubtitleIndex int) string {
|
||
if file == nil || ffmpegSubtitleIndex < 0 || ffmpegSubtitleIndex >= len(file.SubtitleTracks) {
|
||
return ""
|
||
}
|
||
return file.SubtitleTracks[ffmpegSubtitleIndex].Codec
|
||
}
|
||
|
||
// resolveBurnInSubtitle maps a subtitle selection made against requestedFile
|
||
// onto effectiveFile. The 4K guard may replace the requested file with a
|
||
// lower-resolution version whose subtitle streams have a different order; a
|
||
// raw ordinal carried across that switch can burn the wrong language.
|
||
func resolveBurnInSubtitle(requestedFile, effectiveFile *models.MediaFile, requestedIndex int) (int, string, bool) {
|
||
if requestedFile == nil || effectiveFile == nil || requestedIndex < 0 || requestedIndex >= len(requestedFile.SubtitleTracks) {
|
||
return -1, "", false
|
||
}
|
||
if requestedFile.ID == effectiveFile.ID {
|
||
track := effectiveFile.SubtitleTracks[requestedIndex]
|
||
return requestedIndex, track.Codec, true
|
||
}
|
||
|
||
selected := requestedFile.SubtitleTracks[requestedIndex]
|
||
for i, candidate := range effectiveFile.SubtitleTracks {
|
||
if subtitleTracksMatch(selected, candidate) {
|
||
return i, candidate.Codec, true
|
||
}
|
||
}
|
||
return -1, "", false
|
||
}
|
||
|
||
func subtitleTracksMatch(a, b models.SubtitleTrack) bool {
|
||
return strings.EqualFold(strings.TrimSpace(a.Language), strings.TrimSpace(b.Language)) &&
|
||
strings.EqualFold(strings.TrimSpace(a.Codec), strings.TrimSpace(b.Codec)) &&
|
||
strings.EqualFold(
|
||
strings.TrimSpace(firstNonEmptyString(a.Title, a.EmbeddedTitle)),
|
||
strings.TrimSpace(firstNonEmptyString(b.Title, b.EmbeddedTitle)),
|
||
) &&
|
||
a.Forced == b.Forced &&
|
||
a.HearingImpaired == b.HearingImpaired
|
||
}
|
||
|
||
// computeStartSegment returns the HLS segment number corresponding to a seek
|
||
// position given the segment duration. Both remote and local transcode paths
|
||
// use this to align ffmpeg output filenames with the VOD manifest.
|
||
func computeStartSegment(seekSeconds float64, segmentDuration int) int {
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = 2
|
||
}
|
||
if seekSeconds <= 0 {
|
||
return 0
|
||
}
|
||
return int(seekSeconds / float64(segmentDuration))
|
||
}
|
||
|
||
// alignedSeekSeconds snaps an encoded transcode's ffmpeg start position down
|
||
// to the boundary of the segment computeStartSegment assigns it. The synthetic
|
||
// VOD manifest declares segment N to begin at exactly N×segmentDuration;
|
||
// spawning ffmpeg at the raw seek position makes segment N actually begin up
|
||
// to one segment later, and hls.js aligns that content to the declared
|
||
// position — shifting the session's entire timeline (audio, video, and every
|
||
// out-of-band subtitle cue) late by seek mod segmentDuration. Copy-mode
|
||
// sessions serve ffmpeg's real manifest, whose declared timings match the
|
||
// fragments it produces, so they keep the raw seek.
|
||
func alignedSeekSeconds(seekSeconds float64, segmentDuration int, targetVideoCodec string) float64 {
|
||
if strings.EqualFold(targetVideoCodec, "copy") || seekSeconds <= 0 {
|
||
return seekSeconds
|
||
}
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = 2
|
||
}
|
||
return float64(computeStartSegment(seekSeconds, segmentDuration) * segmentDuration)
|
||
}
|
||
|
||
// transcodeStartState holds the common parameters needed to finalize a
|
||
// transcode start (update session state, log, and sync) for both remote
|
||
// and local paths.
|
||
type transcodeStartState struct {
|
||
req transcodeStartRequest
|
||
file *models.MediaFile
|
||
session *playback.Session
|
||
switchedFileID *int
|
||
hwAccel string
|
||
}
|
||
|
||
func effectiveRemoteHWAccel(
|
||
response transcodenode.TranscodeStartResponse,
|
||
request transcodenode.TranscodeStartRequest,
|
||
) string {
|
||
hwAccel := strings.TrimSpace(response.HWAccel)
|
||
if hwAccel == "" {
|
||
hwAccel = strings.TrimSpace(request.HWAccel)
|
||
}
|
||
return hwAccel
|
||
}
|
||
|
||
func transcodeStartReplacement(st transcodeStartState, route playback.TranscodeRoute) playback.SessionReplacement {
|
||
streamBitrateKbps := st.req.TargetBitrateKbps
|
||
if streamBitrateKbps <= 0 {
|
||
streamBitrateKbps = st.file.Bitrate
|
||
}
|
||
transcodeAudio := playback.TranscodesAudio(st.req.TargetCodecAudio)
|
||
baseMethod := semanticPlayMethod(st.session)
|
||
// Persist the byte-affecting recipe (subtitles + segment cadence) so a later
|
||
// offloaded audio switch can rebuild the exact same stream. The session is the
|
||
// only recovery source for offloaded transcodes (no local ts.Opts()). Normalize
|
||
// the cadence to a concrete value so the restart never falls back to 0.
|
||
segmentDuration := st.req.SegmentDuration
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = playback.DefaultSegmentDuration
|
||
}
|
||
|
||
effectiveFileID := st.file.ID
|
||
if st.switchedFileID != nil {
|
||
effectiveFileID = *st.switchedFileID
|
||
}
|
||
return playback.SessionReplacement{
|
||
EffectiveMediaFileID: effectiveFileID,
|
||
StreamState: playback.SessionStreamState{
|
||
PlayMethod: playback.PlayTranscode,
|
||
BasePlayMethod: baseMethod,
|
||
AudioTrackIndex: st.session.AudioTrackIndex,
|
||
TranscodeAudio: transcodeAudio,
|
||
RemuxDVMode: st.session.RemuxDVMode,
|
||
ClientIP: st.session.ClientIP,
|
||
ClientName: st.session.ClientName,
|
||
ClientVersion: st.session.ClientVersion,
|
||
ClientUserAgent: st.session.ClientUserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetResolution: st.req.TargetResolution,
|
||
TargetVideoCodec: st.req.TargetCodecVideo,
|
||
TargetAudioCodec: st.req.TargetCodecAudio,
|
||
TargetBitrateKbps: st.req.TargetBitrateKbps,
|
||
TranscodeHWAccel: st.hwAccel,
|
||
TranscodeNodeURL: route.NodeURL,
|
||
TranscodeTransportID: route.TransportID,
|
||
TranscodeRouteSet: true,
|
||
SubtitleTrackIndex: st.req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: st.req.SubtitleBurnIn,
|
||
SegmentDuration: segmentDuration,
|
||
},
|
||
}
|
||
}
|
||
|
||
func logTranscodeStartState(r *http.Request, st transcodeStartState) {
|
||
slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api",
|
||
"playback_session_id", st.req.SessionID,
|
||
"base_play_method", semanticPlayMethod(st.session),
|
||
"transport_play_method", playback.PlayTranscode,
|
||
"audio_track_index", st.session.AudioTrackIndex,
|
||
"target_codec_video", st.req.TargetCodecVideo,
|
||
"target_codec_audio", st.req.TargetCodecAudio,
|
||
"copy_video_original", strings.EqualFold(st.req.TargetCodecVideo, "copy"),
|
||
"transcode_audio", st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy"),
|
||
)
|
||
}
|
||
|
||
// HandleStartTranscode handles POST /playback/transcode/start.
|
||
func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
var req transcodeStartRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
if req.SessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "session_id is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, req.SessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(req.SessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
requiresVideoTranscode := !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
if !h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||
return
|
||
}
|
||
// Keep the active transport alive through every fallible preflight. A local
|
||
// replacement is retired under the lifecycle lock immediately before spawn;
|
||
// a legacy remote replacement is prepared under a distinct process identity
|
||
// and retired only after the node accepts and the session publishes its successor.
|
||
previousLocalTranscode := h.tm.GetTranscodeSession(req.SessionID)
|
||
previousRoute := sessionTranscodeRoute(session)
|
||
abortCurrentSession := func(reason string, cause error) {
|
||
if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) {
|
||
slog.ErrorContext(r.Context(), "failed to abort playback session", "component", "api",
|
||
"session", req.SessionID,
|
||
"reason", reason,
|
||
"cause", cause,
|
||
"error", abortErr,
|
||
"playback_session_id", req.SessionID,
|
||
)
|
||
}
|
||
}
|
||
|
||
file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID)
|
||
if err != nil {
|
||
if isPlaybackFileLookupMissing(err) {
|
||
abortCurrentSession("load_media_file", err)
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load media file")
|
||
return
|
||
}
|
||
if file == nil {
|
||
abortCurrentSession("load_media_file", nil)
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
requestedFile := file
|
||
if originalFileID := requestedMediaFileID(session); originalFileID > 0 && originalFileID != file.ID {
|
||
originalFile, loadErr := h.loadFileByPreferredID(r.Context(), originalFileID, 0)
|
||
if loadErr != nil || originalFile == nil {
|
||
requestedFile = nil
|
||
} else {
|
||
requestedFile = h.ensurePlaybackProbe(r.Context(), originalFile)
|
||
}
|
||
}
|
||
|
||
// Subtitle ordinals are meaningful only within the file inventory that
|
||
// produced them. New clients echo the media_file_id advertised beside the
|
||
// selected subtitle URL. Clients that omit it retain the legacy behavior of
|
||
// selecting against RequestedMediaFileID so existing restart flows continue
|
||
// to remap original-file ordinals after the 4K guard switches versions.
|
||
subtitleSourceFile := requestedFile
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 {
|
||
switch {
|
||
case req.SubtitleMediaFileID <= 0:
|
||
// Legacy request: requestedFile is the historical source inventory.
|
||
case file != nil && req.SubtitleMediaFileID == file.ID:
|
||
subtitleSourceFile = file
|
||
case requestedFile != nil && req.SubtitleMediaFileID == requestedFile.ID:
|
||
subtitleSourceFile = requestedFile
|
||
default:
|
||
subtitleSourceFile = nil
|
||
}
|
||
if subtitleSourceFile == nil {
|
||
writeError(w, http.StatusUnprocessableEntity, "subtitle_source_unavailable",
|
||
"Media file inventory for the selected subtitle is unavailable")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Subtitle burn-in composites subtitles into the video frames, which is
|
||
// impossible with -c:v copy. If the requested recipe would stream-copy
|
||
// video (e.g. a remux "original" restart that adds burn-in), force an
|
||
// encoding transcode so the burned frames are actually produced instead of
|
||
// the subtitle selection being silently dropped by the filter stage.
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 && strings.EqualFold(req.TargetCodecVideo, "copy") {
|
||
slog.Info("forcing video transcode for subtitle burn-in request",
|
||
"playback_session_id", req.SessionID,
|
||
"subtitle_track_index", req.SubtitleTrackIndex,
|
||
"requested_target_codec_video", req.TargetCodecVideo,
|
||
"effective_target_codec_video", "h264",
|
||
)
|
||
req.TargetCodecVideo = "h264"
|
||
}
|
||
|
||
// A copy-video HLS output of a Dolby Vision Profile 7 source must strip the
|
||
// RPU metadata (the enhancement layer is dropped by stream mapping): raw P7
|
||
// NALs presented as plain HEVC stall hardware decoders. The V3 start path
|
||
// derives this from the plan (videoBitstreamFilterForPlanV3) and the
|
||
// audio-switch restart derives it from the durable session route, but this
|
||
// client-driven restart endpoint historically dropped it — the client asking
|
||
// for "copy" has no way to know the source needs the strip. Derived after
|
||
// the burn-in guard above so a copy request it rewrites to h264 never carries
|
||
// a copy-only bitstream filter.
|
||
//
|
||
// Gated on the per-source probe for the same reason the planner is: a
|
||
// source whose RPU ffmpeg cannot parse turns the filter into a per-packet
|
||
// rejection that never produces a segment, and this endpoint would
|
||
// otherwise put it back on every quality change, seek and burn-in restart
|
||
// of a session the planner had already routed away from it.
|
||
videoBitstreamFilter := ""
|
||
if strings.EqualFold(req.TargetCodecVideo, "copy") &&
|
||
(session.RemuxDVMode == playback.RemuxDVStripToHDR10V3 || file.PrimaryDVProfile() == 7) {
|
||
if playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
|
||
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
|
||
} else {
|
||
slog.WarnContext(r.Context(), "restart dropped the dolby vision rpu strip: source cannot be stripped",
|
||
"component", "api", "playback_session_id", req.SessionID, "file_id", file.ID)
|
||
}
|
||
}
|
||
|
||
// The request-level permission check above intentionally runs before the
|
||
// existing transcode is closed. Recheck when subtitle burn-in normalization
|
||
// has upgraded an allowed copy-video request into actual video encoding.
|
||
if !requiresVideoTranscode && !strings.EqualFold(req.TargetCodecVideo, "copy") &&
|
||
!h.ensureUserTranscodingAllowed(w, r, userID, true) {
|
||
return
|
||
}
|
||
|
||
// 4K transcode guard: if source is 4K and allow_4k_transcode is disabled,
|
||
// switch to an alternate non-4K file version for transcoding.
|
||
// Skip the guard when target_codec_video is "copy" — no actual video
|
||
// encoding happens, so the 4K cost concern doesn't apply.
|
||
var switchedFileID *int
|
||
videoCopy := strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
if file.Resolution == "2160p" && h.SettingsRepo != nil && !videoCopy {
|
||
allow4K, _ := h.SettingsRepo.Get(r.Context(), "allow_4k_transcode")
|
||
if allow4K != "true" {
|
||
alt, altErr := h.findAlternateFile(r.Context(), file)
|
||
if altErr != nil || alt == nil {
|
||
writeError(w, http.StatusUnprocessableEntity, "no_alternate_version",
|
||
"No lower resolution version available for transcoding")
|
||
return
|
||
}
|
||
file = alt
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
switchedFileID = &alt.ID
|
||
}
|
||
}
|
||
if !videoCopy {
|
||
req.TargetResolution = clampEncodedTargetResolution(req.TargetResolution, file.Resolution)
|
||
}
|
||
if requestedFile != nil && file != nil && requestedFile.ID != file.ID {
|
||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||
slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", file.ID,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
if err := preflightPlaybackFile(r.Context(), file, h.MissingMarker, h.EventsHub); err != nil {
|
||
if isPlaybackFileMissing(err) {
|
||
abortCurrentSession("preflight_file", err)
|
||
}
|
||
writePlaybackFilePreflightError(w, err)
|
||
return
|
||
}
|
||
|
||
// Resolve the burn-in track's probed codec so the ffmpeg arg builder can
|
||
// route bitmap codecs (PGS/DVD/DVB) to the overlay filter_complex pipeline
|
||
// instead of the text-only libass subtitles filter. Derived server-side
|
||
// from the effective file rather than trusted from the client.
|
||
subtitleCodec := ""
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 {
|
||
resolvedIndex, resolvedCodec, ok := resolveBurnInSubtitle(subtitleSourceFile, file, req.SubtitleTrackIndex)
|
||
if !ok {
|
||
writeError(w, http.StatusUnprocessableEntity, "subtitle_unavailable_in_version",
|
||
"Selected subtitle track is unavailable in the effective file version")
|
||
return
|
||
}
|
||
if resolvedIndex != req.SubtitleTrackIndex {
|
||
slog.Info("remapped subtitle burn-in track for alternate file",
|
||
"playback_session_id", req.SessionID,
|
||
"subtitle_source_file_id", subtitleSourceFile.ID,
|
||
"effective_file_id", file.ID,
|
||
"requested_subtitle_track_index", req.SubtitleTrackIndex,
|
||
"effective_subtitle_track_index", resolvedIndex,
|
||
)
|
||
}
|
||
req.SubtitleTrackIndex = resolvedIndex
|
||
subtitleCodec = resolvedCodec
|
||
}
|
||
|
||
// A copy-video input seek starts at the demuxer's preceding keyframe. Keep
|
||
// the requested position as FFmpeg's -ss input, but resolve that keyframe
|
||
// before selecting local/offloaded transport so filenames, reconstruction,
|
||
// and the client timeline all describe the media that is actually emitted.
|
||
playbackCfg := h.playbackConfig()
|
||
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
|
||
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
|
||
streamOriginSeconds := 0.0
|
||
if videoCopy {
|
||
streamOriginSeconds = req.SeekSeconds
|
||
if req.SeekSeconds > 0 {
|
||
anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor(
|
||
r.Context(),
|
||
playbackCfg.FFmpegPath,
|
||
file.FilePath,
|
||
req.SeekSeconds,
|
||
req.SegmentDuration,
|
||
)
|
||
if anchorErr != nil {
|
||
slog.ErrorContext(r.Context(), "failed to resolve copy-video seek anchor", "component", "api",
|
||
"playback_session_id", req.SessionID,
|
||
"requested_seek_seconds", req.SeekSeconds,
|
||
"error", anchorErr,
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position")
|
||
return
|
||
}
|
||
streamOriginSeconds = anchor
|
||
startSegmentNumber = anchorSegment
|
||
slog.DebugContext(r.Context(), "resolved copy-video seek anchor", "component", "api",
|
||
"playback_session_id", req.SessionID,
|
||
"requested_seek_seconds", req.SeekSeconds,
|
||
"stream_origin_seconds", streamOriginSeconds,
|
||
"start_segment_number", startSegmentNumber,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Determine whether to run locally or forward to a remote transcode node.
|
||
var plan nodepool.Plan
|
||
if h.NodePlanner != nil {
|
||
estKbps := req.TargetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
|
||
}
|
||
tcNode := plan.TranscodeNode
|
||
|
||
if tcNode != nil {
|
||
// Remote transcode: forward to the assigned node.
|
||
replacementTransportID := req.SessionID
|
||
reconstructionTransportID := ""
|
||
atomicLegacyReplacement := videoCopy && isLegacyTransportSession(session)
|
||
if atomicLegacyReplacement {
|
||
replacementTransportID = newLegacyTransportID(req.SessionID)
|
||
reconstructionTransportID = replacementTransportID
|
||
}
|
||
nodeReq := transcodenode.TranscodeStartRequest{
|
||
SessionID: replacementTransportID,
|
||
InputPath: file.FilePath,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
VideoBitstreamFilter: videoBitstreamFilter,
|
||
SeekSeconds: transportSeekSeconds,
|
||
StreamOriginSeconds: streamOriginSeconds,
|
||
CopySeekAnchorResolved: videoCopy,
|
||
StartSegmentNumber: startSegmentNumber,
|
||
TargetResolution: req.TargetResolution,
|
||
TargetCodecVideo: req.TargetCodecVideo,
|
||
TargetCodecAudio: req.TargetCodecAudio,
|
||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||
SegmentDuration: req.SegmentDuration,
|
||
HWAccel: playbackCfg.HWAccel,
|
||
AudioTrackIndex: session.AudioTrackIndex,
|
||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
RequireReady: atomicLegacyReplacement,
|
||
}
|
||
|
||
nodeResp, status, err := h.startRemotePlaybackTransport(context.Background(), tcNode.URL, nodeReq)
|
||
if err != nil {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
}
|
||
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
|
||
}
|
||
if status != http.StatusAccepted {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
}
|
||
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
|
||
}
|
||
effectiveHWAccel := effectiveRemoteHWAccel(nodeResp, nodeReq)
|
||
startState := transcodeStartState{
|
||
req: req,
|
||
file: file,
|
||
session: session,
|
||
switchedFileID: switchedFileID,
|
||
hwAccel: effectiveHWAccel,
|
||
}
|
||
successorRoute := playback.TranscodeRoute{
|
||
NodeURL: tcNode.URL,
|
||
TransportID: reconstructionTransportID,
|
||
}
|
||
if atomicLegacyReplacement {
|
||
if err := h.commitLegacyRemoteReplacement(
|
||
req.SessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
transcodeStartReplacement(startState, successorRoute),
|
||
); err != nil {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
if errors.Is(err, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "publish legacy transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
if !h.transcodeRouteMatches(req.SessionID, nil, successorRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
} else {
|
||
if err := h.commitLegacyRemoteLastWriter(
|
||
req.SessionID,
|
||
successorRoute,
|
||
transcodeStartReplacement(startState, successorRoute),
|
||
); err != nil {
|
||
slog.ErrorContext(r.Context(), "publish remote transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
}
|
||
|
||
// The remote transcode's full recipe rides the proxy manifest token so the
|
||
// integrated server can re-bind and re-proxy the session after a restart
|
||
// (and a node could someday self-reconstruct from it). Node-side segment
|
||
// reconstruction is a follow-up (see spec multi-node section).
|
||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, session.MediaFileID, tcNode.URL, playback.TranscodeOpts{
|
||
InputPath: nodeReq.InputPath,
|
||
SessionID: req.SessionID,
|
||
TranscodeTransportID: reconstructionTransportID,
|
||
SourceVideoCodec: nodeReq.SourceVideoCodec,
|
||
VideoBitstreamFilter: nodeReq.VideoBitstreamFilter,
|
||
SeekSeconds: nodeReq.SeekSeconds,
|
||
StreamOriginSeconds: nodeReq.StreamOriginSeconds,
|
||
CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved,
|
||
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,
|
||
})
|
||
manifestURL := h.buildProxyManifestURL(card, plan.ProxyNode)
|
||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds))
|
||
return
|
||
}
|
||
|
||
// Local transcode (integrated mode — no transcode nodes available).
|
||
// In distributed mode admins can disable this fallback so the API server
|
||
// never transcodes when no eligible node exists.
|
||
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
|
||
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
|
||
"No transcode node is available and local transcode fallback is disabled")
|
||
return
|
||
}
|
||
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
|
||
// session stay consistent even if the config reloads mid-start.
|
||
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||
return
|
||
}
|
||
|
||
localOpts := playback.TranscodeOpts{
|
||
InputPath: file.FilePath,
|
||
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
|
||
SessionID: req.SessionID,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
VideoBitstreamFilter: videoBitstreamFilter,
|
||
SeekSeconds: transportSeekSeconds,
|
||
StreamOriginSeconds: streamOriginSeconds,
|
||
CopySeekAnchorResolved: videoCopy,
|
||
StartSegmentNumber: startSegmentNumber,
|
||
TargetResolution: req.TargetResolution,
|
||
TargetCodecVideo: req.TargetCodecVideo,
|
||
TargetCodecAudio: req.TargetCodecAudio,
|
||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||
SegmentDuration: req.SegmentDuration,
|
||
FFmpegPath: playbackCfg.FFmpegPath,
|
||
HWAccel: playbackCfg.HWAccel,
|
||
HWDevice: playbackCfg.HWDevice,
|
||
AudioTrackIndex: session.AudioTrackIndex,
|
||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
FastStart: true,
|
||
NodeType: "integrated",
|
||
ExecutionMode: "integrated",
|
||
FFmpegLogSink: h.FFmpegLogSink,
|
||
}
|
||
startState := transcodeStartState{
|
||
req: req,
|
||
file: file,
|
||
session: session,
|
||
switchedFileID: switchedFileID,
|
||
}
|
||
localRoute := playback.TranscodeRoute{}
|
||
var transcodeSession *playback.TranscodeSession
|
||
if videoCopy {
|
||
// Copy-mode successors must coexist with the predecessor until readiness.
|
||
// A generation-scoped directory prevents two ffmpeg processes from writing
|
||
// the same manifest and segments during that overlap.
|
||
outputSubdir := newLegacyTransportID(req.SessionID)
|
||
localOpts.OutputSubdir = outputSubdir
|
||
localOpts.OutputDir = filepath.Join(playbackCfg.TranscodeDir, outputSubdir)
|
||
transcodeSession, err = h.commitLegacyLocalReplacement(
|
||
context.WithoutCancel(r.Context()),
|
||
req.SessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
localOpts,
|
||
func(successor *playback.TranscodeSession) playback.SessionReplacement {
|
||
startState.hwAccel = successor.Opts().HWAccel
|
||
return transcodeStartReplacement(startState, localRoute)
|
||
},
|
||
)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "prepare local copy transcode replacement", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session")
|
||
return
|
||
}
|
||
if !h.transcodeRouteMatches(req.SessionID, transcodeSession, localRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
} else {
|
||
// Encoded legacy starts keep their historical serialized last-writer-wins
|
||
// behavior. Re-read the predecessor under the lifecycle lock instead of
|
||
// rejecting this request because another overlapping start finished first.
|
||
unlock := h.tm.LockSessionLifecycle(req.SessionID)
|
||
currentSession, currentErr := h.sessionMgr.GetSession(req.SessionID)
|
||
if currentErr != nil {
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify playback transport")
|
||
return
|
||
}
|
||
currentRoute := sessionTranscodeRoute(currentSession)
|
||
currentProcessID := remoteTransportID(currentSession)
|
||
h.tm.CloseTranscodeSession(req.SessionID, "")
|
||
transcodeSession, err = h.startLocalPlaybackTransport(r.Context(), localOpts)
|
||
if err != nil {
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session")
|
||
return
|
||
}
|
||
h.tm.RegisterTranscodeSession(req.SessionID, transcodeSession)
|
||
startState.hwAccel = transcodeSession.Opts().HWAccel
|
||
if _, err := h.sessionMgr.ApplyReplacement(req.SessionID, transcodeStartReplacement(startState, localRoute)); err != nil {
|
||
h.tm.CloseTranscodeSessionIf(req.SessionID, transcodeSession, "")
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
unlock()
|
||
if currentRoute.NodeURL != "" {
|
||
h.tm.StopRemoteTranscode(currentProcessID, currentRoute.NodeURL)
|
||
}
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
|
||
// Re-arm the throttler and exit monitor after every Restart of this
|
||
// handler-created session, regardless of which code path triggers it
|
||
// (web segment recovery or an audio switch). Sessions created by
|
||
// jellycompat's own StartTranscode path never had throttler/exit-monitor
|
||
// wiring, so they are unaffected.
|
||
transcodeSession.SetRestartHook(func(ctx context.Context) {
|
||
h.maybeStartThrottler(ctx, transcodeSession)
|
||
h.tm.MonitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||
})
|
||
|
||
h.maybeStartThrottler(r.Context(), transcodeSession)
|
||
h.tm.MonitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||
|
||
// The full reconstruction recipe rides the manifest token so this local
|
||
// transcode can be rebuilt after a server restart (the client re-presents the
|
||
// token on its next manifest/segment request). The token is carried as a
|
||
// query parameter; the manifest rewriter propagates it onto every segment URI.
|
||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, session.MediaFileID, "", transcodeSession.Opts())
|
||
manifestURL := appendStreamToken(
|
||
fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID),
|
||
h.signSessionToken(card),
|
||
)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds))
|
||
}
|
||
|
||
// HandleGetTranscodeManifest handles GET /playback/transcode/{session_id}/master.m3u8.
|
||
// Auth is optional — the session UUID serves as an access token (same pattern
|
||
// as /stream/{session_id}). When auth context is present, ownership is verified.
|
||
//
|
||
// Known-duration encoded sessions expose a synthetic full VOD manifest so the
|
||
// player can seek immediately. Copy-video sessions expose FFmpeg's real
|
||
// keyframe-aligned manifest and use the resolved stream origin returned by
|
||
// HandleStartTranscode.
|
||
func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
session, status, card := h.loadTranscodeServeSession(r, sessionID)
|
||
switch status {
|
||
case playback.SessionMissing:
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
case playback.SessionLoadFailed:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
case playback.SessionForbidden:
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
transcodeSession := h.tm.GetTranscodeSession(sessionID)
|
||
if transcodeSession == nil {
|
||
// No local session — try proxying to remote transcode node.
|
||
if session.TranscodeNodeURL != "" {
|
||
h.touchSessionActivity(sessionID)
|
||
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
|
||
"/transcode/"+remoteTransportID(session)+"/master.m3u8")
|
||
return
|
||
}
|
||
// Local transcode whose process state was lost: reconstruct it from the
|
||
// token recipe. The manifest path has no segment context, so pass -1 (use
|
||
// the token's seek position).
|
||
if card == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
transcodeSession = h.tm.ReconstructTranscode(r.Context(), sessionID, -1, *card)
|
||
if transcodeSession == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
}
|
||
h.touchSessionActivity(sessionID)
|
||
|
||
manifest, err := transcodeSession.BuildPlaybackManifest("segment/", r.URL.RawQuery)
|
||
if err != nil {
|
||
slog.ErrorContext(r.Context(), "build transcode manifest", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Transcode manifest not ready")
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(manifest)
|
||
}
|
||
|
||
// HandleGetTranscodeSegment handles GET /playback/transcode/{session_id}/segment/{name}.
|
||
// Auth is optional — the session UUID serves as an access token.
|
||
func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
session, status, card := h.loadTranscodeServeSession(r, sessionID)
|
||
switch status {
|
||
case playback.SessionMissing:
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
case playback.SessionLoadFailed:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
case playback.SessionForbidden:
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
transcodeSession := h.tm.GetTranscodeSession(sessionID)
|
||
if transcodeSession == nil {
|
||
if session.TranscodeNodeURL != "" {
|
||
h.touchSessionActivity(sessionID)
|
||
segmentName := chi.URLParam(r, "name")
|
||
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
|
||
"/transcode/"+remoteTransportID(session)+"/segment/"+segmentName)
|
||
return
|
||
}
|
||
// Resume near the segment the client is fetching so reconstruct does not
|
||
// restart from the original seek point and stall. A non-segment name
|
||
// (e.g. init.mp4) parses as negative and falls back to the token position.
|
||
requestedSegment := -1
|
||
if segNum, parseErr := playback.ParseSegmentNumber(chi.URLParam(r, "name")); parseErr == nil {
|
||
requestedSegment = segNum
|
||
}
|
||
if card == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
transcodeSession = h.tm.ReconstructTranscode(r.Context(), sessionID, requestedSegment, *card)
|
||
if transcodeSession == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
}
|
||
h.touchSessionActivity(sessionID)
|
||
|
||
segmentName := chi.URLParam(r, "name")
|
||
segmentPath, err := transcodeSession.GetSegment(segmentName)
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||
segNum, parseErr := playback.ParseSegmentNumber(segmentName)
|
||
if parseErr == nil {
|
||
now := time.Now()
|
||
decision := transcodeSession.SegmentRecoveryDecision(segNum, now)
|
||
lastProducedAgeMS := int64(-1)
|
||
if !decision.Progress.LastProducedAt.IsZero() {
|
||
lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds()
|
||
}
|
||
slog.InfoContext(r.Context(), "transcode segment missing", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
if decision.Wait {
|
||
slog.InfoContext(r.Context(), "transcode segment wait", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout)
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
}
|
||
}
|
||
|
||
// If the segment is still missing (timed out, or outside the
|
||
// active encode range), either restart at the exact manifest-derived
|
||
// timeline position or return 404 for copy-mode segments outside the
|
||
// current manifest window.
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout {
|
||
seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum)
|
||
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
|
||
slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "api", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID)
|
||
}
|
||
|
||
// Copy-mode with an unresolved seek target (ok=false, no error)
|
||
// means the manifest can't place this segment yet. Don't restart
|
||
// at a fabricated position; surface ErrSegmentNotFound so the
|
||
// client retries while the session keeps producing manifest.
|
||
// Mirrors the transcode-node guard in
|
||
// internal/transcodenode/server.go.
|
||
if !ok && seekErr == nil && transcodeSession.IsCopyVideo() {
|
||
err = playback.ErrSegmentNotFound
|
||
}
|
||
|
||
if ok {
|
||
slog.InfoContext(r.Context(), "transcode seek restart", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"seek_seconds", seekSeconds,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
if restartErr := h.tm.RestartSessionLocked(
|
||
context.WithoutCancel(r.Context()),
|
||
sessionID,
|
||
transcodeSession,
|
||
seekSeconds,
|
||
segNum,
|
||
); restartErr == nil {
|
||
// Throttler + exit monitor re-arm via the session's
|
||
// restart hook.
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 30*time.Second)
|
||
if err == nil && strings.EqualFold(transcodeSession.Opts().TargetCodecVideo, "copy") {
|
||
// Copy-mode seeks can resume as soon as the target segment
|
||
// exists, but that sometimes leaves the player one segment
|
||
// away from stalling while FFmpeg catches up. Briefly wait
|
||
// for a single lookahead fragment when available so the
|
||
// first resumed playback window is less brittle.
|
||
nextSegmentName := fmt.Sprintf("seg_%05d.m4s", segNum+1)
|
||
_, _ = transcodeSession.WaitForSegment(nextSegmentName, 1200*time.Millisecond)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if transcodeSession.IsRunning() {
|
||
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
|
||
// Wait briefly — the init segment is written almost immediately.
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 10*time.Second)
|
||
}
|
||
}
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSegmentNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load segment")
|
||
return
|
||
}
|
||
|
||
// Report segment download for throttle tracking.
|
||
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
|
||
transcodeSession.ReportSegmentDownloaded(segNum)
|
||
}
|
||
|
||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
http.ServeFile(w, r, segmentPath)
|
||
}
|
||
|
||
// buildProxyManifestURL signs a stream token carrying the session's full
|
||
// reconstruction recipe and builds the manifest URL. proxyNode is the planner's
|
||
// pick; when nil the URL falls back to the API-local path, where the token rides
|
||
// the ?st= query parameter so the integrated server can reconstruct from it.
|
||
func (h *PlaybackHandler) buildProxyManifestURL(card playback.RecipeCard, proxyNode *nodepool.Node) string {
|
||
token := h.signSessionToken(card)
|
||
localURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", card.SessionID)
|
||
if proxyNode == nil {
|
||
return appendStreamToken(localURL, token)
|
||
}
|
||
if token == "" {
|
||
return localURL
|
||
}
|
||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
|
||
// proxyToTranscodeNode forwards a request to the remote transcode node.
|
||
func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, transcodeNodeURL, path string) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
targetURL := transcodeNodeURL + path
|
||
// Capture the signed stream token ("st") before stripping it from the URL.
|
||
// We forward it out-of-band as a header so the node can reconstruct after a
|
||
// self-restart, while keeping it out of the forwarded/logged URL.
|
||
stToken := r.URL.Query().Get("st")
|
||
// Strip the signed stream token ("st") before forwarding/logging: it is a
|
||
// 24h bearer reconstruction descriptor exposing media path + recipe claims.
|
||
// Other query params are preserved.
|
||
query := r.URL.Query()
|
||
query.Del("st")
|
||
if encoded := query.Encode(); encoded != "" {
|
||
targetURL += "?" + encoded
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL, nil)
|
||
if err != nil {
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+h.JWTSecret)
|
||
// Best-effort forward of the stream token as a header so the node's
|
||
// reconstruct path (X-Silo-Stream-Token) can rebuild after a self-restart.
|
||
// Verify at the API boundary and confirm it belongs to this session; an
|
||
// invalid or missing token never blocks the live proxy. validToken is kept so
|
||
// the same verified token can be re-injected into the node's manifest segment
|
||
// URIs below.
|
||
var validToken string
|
||
if stToken != "" && h.JWTSecret != "" {
|
||
claims, verifyErr := streamtoken.Verify(stToken, h.JWTSecret)
|
||
if verifyErr == nil && claims.SessionID == sessionID {
|
||
req.Header.Set("X-Silo-Stream-Token", stToken)
|
||
validToken = stToken
|
||
} else if verifyErr != nil {
|
||
slog.WarnContext(r.Context(), "stream token not forwarded to transcode node", "component", "api", "error", verifyErr, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "api", "error", err, "url", targetURL, "playback_session_id", sessionID)
|
||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// The node strips "st" from the request query (kept out of node URLs/logs),
|
||
// so the segment/init URIs in the manifest it builds carry no token. Without
|
||
// it, a segment fetched after a node or API restart cannot reconstruct the
|
||
// session and 404s. Re-inject the client-facing token into every URI at this
|
||
// boundary so the client's later segment requests carry "st" again. Only the
|
||
// manifest body is rewritten; segments stream through untouched.
|
||
if validToken != "" && resp.StatusCode == http.StatusOK && strings.HasSuffix(path, ".m3u8") {
|
||
body, readErr := io.ReadAll(resp.Body)
|
||
if readErr != nil {
|
||
slog.ErrorContext(r.Context(), "read transcode node manifest", "component", "api", "error", readErr, "url", targetURL, "playback_session_id", sessionID)
|
||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||
return
|
||
}
|
||
rewritten := playback.AppendManifestQueryParam(body, streamTokenParam, validToken)
|
||
for k, vv := range resp.Header {
|
||
if http.CanonicalHeaderKey(k) == "Content-Length" {
|
||
continue
|
||
}
|
||
for _, v := range vv {
|
||
w.Header().Add(k, v)
|
||
}
|
||
}
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
|
||
w.WriteHeader(resp.StatusCode)
|
||
_, _ = w.Write(rewritten)
|
||
return
|
||
}
|
||
|
||
for k, vv := range resp.Header {
|
||
for _, v := range vv {
|
||
w.Header().Add(k, v)
|
||
}
|
||
}
|
||
// Proxied transcode output can stream past the server's absolute
|
||
// WriteTimeout; roll the write deadline with progress instead.
|
||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||
sw.WriteHeader(resp.StatusCode)
|
||
io.Copy(sw, resp.Body)
|
||
}
|
||
|
||
// maybeStartThrottler reads throttle settings and starts the throttler if enabled.
|
||
func (h *PlaybackHandler) maybeStartThrottler(ctx context.Context, session *playback.TranscodeSession) {
|
||
if h.SettingsRepo == nil {
|
||
return
|
||
}
|
||
enableThrottle, _ := h.SettingsRepo.Get(ctx, "enable_transcode_throttle")
|
||
if enableThrottle != "true" {
|
||
return
|
||
}
|
||
thresholdStr, _ := h.SettingsRepo.Get(ctx, "transcode_throttle_seconds")
|
||
threshold := 300 // default
|
||
if v, err := strconv.Atoi(thresholdStr); err == nil && v > 0 {
|
||
threshold = v
|
||
}
|
||
session.StartThrottler(threshold)
|
||
}
|
||
|
||
// findAlternateFile finds a non-4K file version for the same content.
|
||
// Prefers SDR over HDR, then highest resolution, then highest bitrate.
|
||
func (h *PlaybackHandler) findAlternateFile(ctx context.Context, source *models.MediaFile) (*models.MediaFile, error) {
|
||
if h.FileVersionFetcher == nil {
|
||
return nil, fmt.Errorf("file version fetcher not configured")
|
||
}
|
||
|
||
var files []*models.MediaFile
|
||
var err error
|
||
if source.EpisodeID != "" {
|
||
files, err = h.FileVersionFetcher.GetByEpisodeID(ctx, source.EpisodeID)
|
||
} else {
|
||
files, err = h.FileVersionFetcher.GetByContentID(ctx, source.ContentID)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Filter to non-4K candidates.
|
||
candidates := make([]*models.MediaFile, 0, len(files))
|
||
for _, f := range files {
|
||
if f.ID == source.ID || f.Resolution == "2160p" {
|
||
continue
|
||
}
|
||
if source.EditionKey != "" && f.EditionKey != source.EditionKey {
|
||
continue
|
||
}
|
||
if source.EditionKey == "" && f.EditionKey != "" {
|
||
continue
|
||
}
|
||
if source.PresentationGroupKey != "" && f.PresentationGroupKey != "" && f.PresentationGroupKey != source.PresentationGroupKey {
|
||
continue
|
||
}
|
||
if source.PresentationKind != "" && f.PresentationKind != "" && f.PresentationKind != source.PresentationKind {
|
||
continue
|
||
}
|
||
candidates = append(candidates, f)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// Sort: SDR before HDR, then highest resolution, then highest bitrate.
|
||
sort.Slice(candidates, func(i, j int) bool {
|
||
a, b := candidates[i], candidates[j]
|
||
// Prefer SDR over HDR (SDR = !HDR, so !HDR < HDR means SDR first).
|
||
if a.HDR != b.HDR {
|
||
return !a.HDR
|
||
}
|
||
aRes := resolutionRank(a.Resolution)
|
||
bRes := resolutionRank(b.Resolution)
|
||
if aRes != bRes {
|
||
return aRes > bRes
|
||
}
|
||
return a.Bitrate > b.Bitrate
|
||
})
|
||
|
||
return candidates[0], nil
|
||
}
|
||
|
||
const (
|
||
transcodeResolution2160p = "2160p"
|
||
transcodeResolution1080p = "1080p"
|
||
transcodeResolution720p = "720p"
|
||
transcodeResolution480p = "480p"
|
||
transcodeResolution420p = "420p"
|
||
transcodeResolution328p = "328p"
|
||
)
|
||
|
||
// resolutionRank returns a numeric rank for resolution sorting.
|
||
func resolutionRank(res string) int {
|
||
height, known := transcodeResolutionHeight(res)
|
||
if !known {
|
||
return 0
|
||
}
|
||
|
||
switch {
|
||
case height >= 2160:
|
||
return 4
|
||
case height >= 1080:
|
||
return 3
|
||
case height >= 720:
|
||
return 2
|
||
case height >= 480:
|
||
return 1
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
|
||
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
|
||
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
|
||
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
|
||
return requestedResolution
|
||
}
|
||
return sourceResolution
|
||
}
|
||
|
||
func transcodeResolutionHeight(resolution string) (int, bool) {
|
||
switch resolution {
|
||
case transcodeResolution2160p:
|
||
return 2160, true
|
||
case transcodeResolution1080p:
|
||
return 1080, true
|
||
case transcodeResolution720p:
|
||
return 720, true
|
||
case transcodeResolution480p:
|
||
return 480, true
|
||
case transcodeResolution420p:
|
||
return 420, true
|
||
case transcodeResolution328p:
|
||
return 328, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|