Compare commits

...
Author SHA1 Message Date
Quick104andClaude Opus 5 1a9a6084c0 fix(settings): keep the intro-skip profile column truthful
Review of #660 found the legacy user_profiles.auto_skip_intro column
could be moved by an enum write and never moved back, and a new
three-way control landing on the web device screen beside the switch it
replaces.

- The column now tracks every canonical profile-scope change to either
  half of the pair. Previously only playback.intro_skip_mode wrote it,
  so an "always" write pinned it true: a following auto_skip_intro=false
  stored false in the rows while GET /profiles kept saying true, and a
  DELETE that went back to inheriting left the column asserting a choice
  nobody held — which the web player reads straight out of the profile
  DTO and acts on.
- Clearing a profile-scope row now falls the column back to the
  contract default, read from the manifest rather than spelled in the
  handler.
- playback.intro_skip_mode is hidden from the web device settings
  screen for this release. It had no group entry, so it fell through to
  its manifest category and rendered under Picture while
  "Auto-skip intros" stayed under Episodes: two controls for one
  preference, each silently rewriting the other through the mirror, and
  a "never" the player cannot honour yet. Its group is recorded so
  unhiding it in the player follow-up is one deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:13:56 -04:00
af974edfe9 feat(settings): add playback.intro_skip_mode and deprecate auto_skip_intro
Skipping intros stops being a switch and becomes a three-way choice —
never / ask / always — matching what Jellyfin offers and giving viewers a
way to turn the prompt off, which the boolean could not express.

Contract revision 6 → 7: adds playback.intro_skip_mode (enum, default
"ask", profile + profile_device scopes) and marks playback.auto_skip_intro
deprecated without removing it. Every shipped client still reads the
boolean, so for one release the server keeps the pair in step at write
time: canonical PUT/DELETE, the legacy /profiles route, and the legacy
runtime /settings/{key} route all land both rows, and a profile-scope enum
write refreshes user_profiles.auto_skip_intro so GET /profiles stays
truthful. Existing rows are carried onto the new key by a Goose migration
(Postgres) and an InitSchema twin (per-user SQLite); the settings-migrate
planner emits the companion for installs whose backfill runs later.

The spec in docs/design/2026-08-16-intro-skip-mode.md also defines the
prompt state machine every client (web, Android, Apple; browser, tablet,
mobile, TV) implements against this key. It builds on the Android TV
Skip Intro work in silo-android#210 — wall-clock timer, rebuffer-vs-pause
debounce, root-level key handling.

Co-authored-by: evulhotdog <365456+evulhotdog@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:38:19 -04:00
edd919c5f7 fix(notifications): restore store capabilities and batch series resolution (#647)
* fix(notifications): restore store capabilities and batch series resolution

Marking or unmarking a large series spent minutes in the interest-tracking
layer. Two independent defects, both in internal/notifications.

The decorator embeds the userstore.UserStore *interface*, which promotes only
that interface's methods. Every optional capability the backing store
implements was therefore invisible through the wrapper, and because callers
reach these by type assertion with a working fallback, the loss was silent:
no error, no test failure, just the slow path. cmd/silo wraps the provider
unconditionally when notifications are enabled, so in production
userstore.MarkWatchedBatch's assertion failed and #645's transactional batch
write never ran. AddVisibleHistory, VisibleHistoryTimestamps, and the
jellycompat series rollup were degraded the same way.

Forward all four capabilities explicitly, and add compile-time assertions so a
future capability is a build error rather than a silent slowdown.

Separately, the interest flush resolved each queued item to its series with one
query apiece, then deduped the results. A whole-series mark queues one mutation
per episode, so thousands of lookups collapsed to a single series after paying
for all of them. Resolve the batch in one query and dedupe from that; the
single-item resolveSeriesID had no other callers and is removed rather than
left to drift.

Measured on the dev server against a 6,375-episode series:

  mark    5.2s  -> ~0.9s
  unmark  116s  -> ~8.6s
  episodes index scans  18.5M -> 19,298

Fixes #646.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): queue interest recomputes on the batch fallback path

Review catch (CodeRabbit on #647). When the backing store lacks
WatchedBatchWriter, the forward handed the work to the generic helper against
s.UserStore — the inner store — so this decorator's own MarkWatched hook never
fired and nothing queued an interest recompute. Marking a series watched on
such a backend updated progress and history but left profile_series_interest
stale until an unrelated mutation or the rebuild task touched the series.

Queue by requested target on that path, and do so even when the helper returns
an error: the fallback is a per-target loop, so a mid-loop failure still leaves
earlier targets written. A redundant queue costs one recompute; a missing one
is silent staleness. The transactional path keeps queuing from written entries
only on success, because on error nothing landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): advertise the series rollup only when the store has it

Review catch (Codex on #647). Forwarding SeriesEpisodeWatchCounts
unconditionally made the wrapper always satisfy SeriesEpisodeRollupStore, even
over the per-user SQLite backend, which has no catalog tables and cannot answer
the query. Callers read "implements the interface" as "can do this", so every
jellycompat series detail and browse would enter the fast path, take the error,
log "series watch rollup query failed", and only then fall back — turning an
expected capability absence into recurring warning noise on requests that
succeeded.

Make the capability conditional on the backing store, the way DeviceRegistry
already is, via wrapper types composed in ForUser. The remaining capabilities
stay unconditional: those have real generic fallbacks and every store can
perform them.

Tests cover both directions — a SQLite-backed wrapper must not advertise the
rollup, and a rollup-capable store must keep it (and reach it) through the
wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:00:02 -04:00
5fdb5d73ac feat(diagnostics): expand client log attribute registry (#649)
Adds the attribute keys the Apple and Android clients need to record
startup, network, auth, and playback behavior in diagnostics reports.

The four playback keys were already being emitted by the Apple client
but were never registered here, so they were silently dropped by this
collector and hard-rejected by the hosted one. Registering them makes
the canonical registry the superset it was always meant to be.

position_seconds is deliberately registered as position_ms (integer):
attrValueType has only string and integer, and a millisecond ordinal
avoids adding a float type for a single key. launch_type is a string
for the same reason, rather than a bool.

New keys:
  playback:  session_id, play_method, reason, position_ms
  lifecycle: phase, duration_ms, outcome, reason, launch_type
  network:   outcome, error_code, attempt

Purely additive; no existing key changes type or is removed.
TestAttrRegistryStaysInSync keeps the Go map and the JSON in lockstep.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:39:17 -07:00
QuickandGitHub febf5c4a28 fix(transcode): allow catalogued symlink media (#644) 2026-08-14 23:16:09 -04:00
54507d236a fix(watchstate): make series mark-watched atomic and unload-proof (#645)
Marking a large series as watched only marked some episodes when the user
navigated away or closed the tab. POST /watched/{id} expanded a series to
every episode and then did four sequential DB round-trips per episode — a
duration lookup, a progress upsert, a stable-identity resolution (itself an
episode lookup plus a series provider-ID lookup), and a history insert — all
on the request context with no transaction. A 200-episode series was ~800
sequential queries, each committing on its own, so a disconnect mid-loop left
everything already committed in place.

Server:
- episodeTargets now resolves durations through the existing batched
  listEpisodeFiles helper instead of one file query per episode. The shared
  mediaFileDurationSeconds keeps the batched and single-item paths in step.
- ResolveHistoryIdentities resolves a whole series in one episode query plus
  one provider-ID query per distinct series, behind optional-capability
  interfaces with per-ID fallbacks.
- New userstore.WatchedBatchWriter capability, implemented transactionally in
  both Postgres and SQLite, with a per-target fallback for stores that lack
  it. recordMarkWatched and jellycompat's recordMarkWatchedBatch share it.

A cancelled request now rolls back to "nothing marked" rather than stranding
a half-watched series, which is the correct all-or-nothing semantic and needs
no detached context.

Client: the watched mutation sends keepalive so the request survives
navigation and tab close, plus an optimistic played flip with rollback,
following the existing favorites.ts pattern.

Routing jellycompat through the shared path would have clobbered known
durations with 0, since that caller supplies none; the batch upsert only ever
advances a duration, matching what MarkProgressBatch did before.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:15:56 -04:00
db6562531b fix(watchsync): import MDBList episode history leaves (#641)
* fix(watchsync): import MDBList episode history leaves

* docs(watchsync): describe MDBList per-play history

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-14 18:49:50 -04:00
af37c53838 feat(collections): add profile-scoped default sorting (#489)
* feat(collections): add profile-scoped default sorting

* fix(collections): address review feedback

* chore(collections): preserve rebase migration coverage

* chore(collections): satisfy changed-line lint

* fix(collections): address remaining review feedback

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-14 15:45:47 -04:00
76a44e38e7 Update web UI icons to the new vector artwork (#636)
* Update web UI icons to the new vector artwork

Re-render the favicon, apple-touch, web-app, maskable and 1024px icons from
the SVG masters in Silo-Server/silo-branding, plus the repo-level
assets/icon.png.

maskable-icon-512.png now keeps the mark inside the 80% safe circle. The
previous one was full-bleed art, so launchers applying a circular mask were
cropping the top and bottom of the mark.

site.webmanifest theme_color is left alone; it is UI chrome, not an icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iRtwoxswjkgRqcgAeczVP

* Centre the maskable mark on its bounding box

Sizing the safe zone from the mark's minimum enclosing circle kept the ink
inside the circle but placed it visibly right of centre, because that circle
sits near the mark's left edge. Size from the largest radius about the
bounding-box centre instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iRtwoxswjkgRqcgAeczVP

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:05:59 -04:00
e5c29eb8e0 feat(activity): report exact client app version, build, and channel (#631)
* feat(activity): report exact client app version, build, and channel

The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.

Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.

Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.

Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.

Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.

Part of the client-version-visibility work spanning silo-android and
silo-apple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): resolve client identity without polluting client_version

Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:

The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.

An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.

Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.

playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.

normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.

replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.

Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): clamp client identity at the request boundary, by runes

Follow-up to bot review on the previous commit.

The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.

normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.

Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): strip control characters from client identity

A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.

normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.

Reported by Codex review on b43b7ef06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): satisfy goconst and misspell on the changed log lines

CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.

The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.

Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(playback): require context-aware session starts

startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.

SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.

Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:18:47 -04:00
QuickandGitHub ebc99ac09a fix(watchsync): import current MDBList watched history (#628)
Parse current MDBList watched payloads and expand aggregate show and season markers to local episode leaves without duplicating overlapping items.
2026-08-13 11:10:36 -04:00
c4f38b50a6 chore(playback): regenerate protocol_v3 fixtures for output_change_v1 (#622)
The output_change_v1 capability feature was added to the playback feature
list without regenerating the checked-in protocol_v3 fixtures, so
`make verify-playback-fixtures` fails on main and therefore on every open
pull request.

Regenerated with `make playback-fixtures`. The diff is 5 insertions and no
deletions -- the same missing feature string in each fixture.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:36:10 -04:00
33a57ae672 fix(playback): route v3 direct play and remux through proxy nodes (#620)
* fix(playback): route v3 direct play and remux through proxy nodes

Protocol v3 consulted the node planner only for the HLS deliveries, so
`original_http` and `server_remux_progressive` sessions returned an
API-local `/stream/{session_id}` URL and the API node served the bytes —
ServeDirectPlay for direct play, a locally spawned ffmpeg for the remux.
An operator running dedicated proxy nodes still saw all of that egress on
the API node.

The capability already existed: the proxy implements /stream/direct and
/stream/remux, and the Jellyfin-compat transport already plans a proxy for
exactly these two methods. Native v3 was the only surface skipping it, so
Jellyfin clients routed correctly on a deployment where Silo's own clients
did not. This wires the same shape into the v3 identity transport rather
than inventing a second selection path.

The proxy serves from the stream token alone, so the token now carries the
media path, the file's Dolby Vision profile (a P7 remux must strip the
dangling RPU) and the audio-only flag (which picks audio/mp4 over
video/mp4, the MIME the plan promised). RecipeCard models none of the
three; a missing claim would not fail loudly, it would serve a subtly
different stream than the plan promised.

Two related fixes:

- Proxy direct play served via http.ServeFile, which sets no strong ETag.
  direct_stream_resume_v1 depends on the ETag ServeDirectPlay sets before
  ServeContent, so routing direct play to a proxy without this would have
  silently broken resumable direct streams: If-Range never validates and a
  resumed range restarts at 200. The proxy now uses the same serve path.

- playback.local_transcode_fallback was only checked in the HLS branch, so
  a progressive remux that converts audio still spawned ffmpeg locally on
  an API-only node with the setting disabled. Identity deliveries now
  honor the gate too — direct play still falls back locally, since moving
  bytes is not transcode work and single-node deployments must keep
  working.

Falling back to the API-local path when no proxy is eligible preserves
single-node behavior, and a planner reservation is released whenever the
session does not actually reach a proxy.

Closes #619

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(playback): validate proxy recipes and keep proxy sessions alive

Addresses three P1 findings on the proxy-transport change.

Proxies do run ffmpeg — /stream/remux converts audio and strips Dolby
Vision RPUs — but they exposed no capability endpoint, so unlike the HLS
offload path nothing checked that the selected proxy could execute the
transformations a plan froze. A pool whose proxies carry a different
ffmpeg build (rolling upgrade, custom image) would fail at stream time: a
missing aac encoder 500s, a missing dovi_rpu filter is refused outright by
the remux itself. Proxies now serve /hw-capabilities in the same shape and
at the same path as a transcode node, and identity planning validates the
frozen recipe against the selected proxy, falling back to a node that can
do the work. A proxy that does not answer is treated as incapable rather
than assumed good: an older proxy predating the endpoint is exactly the
mismatched build the check exists to catch. Direct play copies bytes and
needs no recipe, so it skips the probe entirely.

meteredResponseWriter implemented neither Unwrap nor SetWriteDeadline, so
RollingDeadlineWriter could not install its stall deadline on any proxy
stream. With the standalone proxy running WriteTimeout 0 there was no
server-level guard behind it, so a client that stopped reading without
closing its connection would block a write forever, holding the session,
the file, the goroutine and the connection.

A proxy-served session never produces a transport request on the API node,
so activeTransportCount — what protects a local stream from the idle
reaper — stays zero and a heartbeat gap longer than the active grace would
reap a healthy stream, after which progress, stop and replan all fail with
session-not-found while bytes still flow. Sessions are now marked as
remotely transported, which widens their idle windows rather than granting
immunity: this manager has no absolute session lifetime, so unconditional
immunity would leak a session forever when a client disappears without
stopping. The mark is always set on commit, so a re-plan that moves a
session back onto the API clears a stale one.

Also adopts the exported transformation constants in the tests and covers
the effective-recipe bitrate branch, per review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(playback): pick capable sibling proxies and refresh transport locality

Narrow proxy selection by capability *before* selection rather than
rejecting a single round-robin pick afterwards. Abandoning the pool on one
mismatch meant a capable proxy with free capacity sat unused while
playback either ran ffmpeg on the API node or, with
playback.local_transcode_fallback disabled, was refused outright — the
exact api/proxy split this branch targets, during exactly the rolling
ffmpeg upgrade the capability check exists for. PlanSessionWith now
applies its eligibility predicate to the proxy on proxy-only plans (the
proxy is the executor there), mirroring how HLS filters transcode nodes,
and the planner grows ProxyNodeURLs to match TranscodeNodeURLs. Direct
play still skips the probe: it copies bytes and needs no recipe.

Every committed route now records transport locality, not just the
identity-proxy one. A session replanned from a proxy onto the integrated
transcoder previously kept a stale remote-transport mark, and the widened
idle grace it grants would hold that session's stream and transcode slots
for five minutes after the local stream disconnected without an explicit
stop. The remote HLS route sets it too — it also hands the client an
absolute proxy URL that never reaches this server.

The proxy's CORS config exposed no response headers, so cross-origin
JavaScript could send the If-Range/Range request headers it already allows
but never read the ETag, Accept-Ranges or Content-Range needed to build
them. direct_stream_resume_v1 silently degraded to a full restart whenever
the proxy was on a different origin than the web app, which is the normal
deployment.

Also regenerates internal/playback/testdata/protocol_v3 and the schema
fixtures, which were stale for output_change_v1 since #613/#617 and failed
CI on every branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(playback): restore the alternate-version fallback for burn-in refusals

#617 renamed the terminal a burn-in-forced adaptation reports: when the
subtitle burn requirement is the sole trigger, an HDR source that cannot
be re-encoded now returns subtitle_conversion_unsupported instead of
hdr_transcode_unsupported, so the refusal names the thing the viewer can
actually act on.

terminalAllowsAlternateFileV3 was not updated to match, and it gates the
alternate-version retry on the old reason strings. That silently retired
the fallback for exactly the case its own comment describes — a bitmap
subtitle needing burn-in that an HDR source cannot support while an SDR
alternate can. Playback was refused outright instead of switching to the
version that can serve it.

Adds the new reason to the gate and covers it directly, so a future
rename of a refusal reason fails on the gate rather than only on the
end-to-end replan test.

Also drops debug instrumentation that was committed by mistake in
TestHandleReplanPlaybackV3BitmapSubtitleFallsBackFromHDRToSDRVersion; the
assertion is back to its original form and now passes on the merits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:21:27 -04:00
QuickandGitHub 3978a54819 fix(web): make sidebar logo link to home (#626) 2026-08-13 10:14:12 -04:00
ee9356aab3 fix(playback): claim Safari DV/HDR10 decode evidence and preserve dvvC in remuxes (#617)
* fix(playback): claim Safari DV/HDR10 decode evidence and preserve dvvC

Follow-up to #613 for #609: Safari 26 reports dynamic-range: standard even
on an XDR display, and answers canPlayType "probably" only for dvh1/hvc1
sample entries, never dvhe/hev1. The web probe gated every structured HDR
claim on that media query and probed only dvhe, so the planner still saw
empty hdr_details and terminated with hdr_transcode_unsupported.

- Run the Dolby Vision and HDR10 shape probes unconditionally; the
  dynamic-range query survives only as the best-effort hdr output boolean.
- Probe dvh1+dvhe per DV profile and hvc1+hev1 for the exact HDR10
  Media Capabilities shape; either definitive answer earns the claim.
- Tag preserved-DV remuxes dvh1 with -strict unofficial: FFmpeg omits the
  dvvC configuration record under either tag without it, so the previous
  dvhe output carried no DV signaling at all.
- Scale the screen-derived max_resolution by devicePixelRatio so 2x
  panels stop advertising 1080p.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): align DV/HDR10 claims with the sample entries the remux emits

Review follow-ups on #617 (Codex P1s):

- Probe only dvh1: a browser answering "probably" solely for dvhe has
  given no evidence for the dvh1-tagged file the preserve remux delivers,
  so that answer no longer earns a Dolby Vision claim; such browsers keep
  the validated HDR10 fallback.
- Label the explicit v3 HDR10 strip output hvc1 so the file matches the
  hvc1 evidence the web probe accepts; legacy/auto strips keep hev1.
- Cover preserve tagging for profiles 5 and 8, and exercise the
  mode-to-argument mapping through StartRemuxWithDVMode instead of
  passing the tag flag directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): name the subtitle when burn-in alone forces a refused transcode

Selecting a PGS/VOBSUB track on a client without a bitmap renderer forces
a burn-in transcode. When that transcode cannot run, the terminal blamed
the HDR pipeline (hdr_transcode_unsupported) or the 4K policy
(no_alternate_version) — problems that were not blocking playback, since
deselecting the subtitle restores the previous route.

- When the burn requirement is the sole trigger of the adaptation, the
  HDR and 4K-policy terminals emit subtitle_conversion_unsupported with
  a message naming the burn requirement and the actual blocker. A range
  the client genuinely cannot take keeps the HDR terminal.
- describePlanTerminal passes the server's subtitle message through
  instead of flattening every subtitle_* reason to one generic sentence.
- The web player toasts the refusal when it rolls the subtitle selection
  back; previously the only surface was the quality menu, which a user
  who just picked a subtitle has no reason to open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): require decode evidence for the exact hvc1 entry and gate the disabled-transcode subtitle terminal

Review follow-ups on #617:

- The HDR10 probe accepts only hvc1.2.4.L153.B0: the explicit v3 strip
  remux labels its output hvc1, so an hev1-only decodingInfo answer is
  evidence for bytes Silo never sends and earns no claim (Codex P1).
- The disabled-transcode branch blames the subtitle only when the burn
  requirement was the sole adaptation trigger; other causes keep
  transcoding_disabled. In practice a bitmap selection without transcode
  terminals in the subtitle policy before this branch, but the guard
  keeps the sole-cause invariant if that ordering ever changes
  (CodeRabbit / Codex P2).
- Protocol doc scopes the hvc1 labeling to the explicit v3 strip path;
  legacy/auto strips keep hev1 (CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:14:52 -04:00
aaf93fd2a1 feat(subtitles): add external provider capability probe (#618)
A deployment with no external subtitle providers configured answers
POST /api/v1/subtitles/search with 200 {"results": null} — byte-identical
to a search that ran and matched nothing. Clients had no way to tell "this
server cannot do that" from "nothing found for this file", so they offered
an in-player search entry point that could never succeed. That reached us
as a bug report against the clients for a feature that was simply not
enabled here.

Add GET /api/v1/subtitles/providers/status, following the per-subsystem
capability convention already used by /subtitles/ai/status and
/items/trailers/capability:

    {"schema_version": 1, "enabled": true, "providers": ["opensubtitles"]}

Provider names are safe for any authenticated viewer — they already travel
in every SubtitleResult.provider and DownloadedSubtitle.provider. The
credentials behind them stay in the admin-only provider config.

Two details worth noting for review:

The whole /subtitles group is conditional on DB + S3 + repo, so on a
storage-less deployment the probe would 404 — leaving clients to interpret
exactly the ambiguous signal the probe exists to replace. An else branch
mounts the probe alone, answering enabled:false. Only one of the two
groups registers per boot.

The path is two segments on purpose: a bare /providers would shadow the
one-segment /{media_file_id} route, while /providers/status never competes
with it in chi.

Search and download behavior are unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:22:12 -04:00
QuickandGitHub 47e45f4d67 feat(downloads): relay node-local artifacts through proxies (#608)
* feat(downloads): relay node-local artifacts through proxies

* fix(downloads): harden remote artifact relay

* fix(downloads): close distributed artifact review gaps

* fix(downloads): bound relays and refresh origins

* fix(downloads): recover stalled remote artifacts

* fix(transcode): report session cleanup failures

* fix(downloads): harden artifact cleanup recovery

* fix(downloads): close remote artifact lifecycle races

* fix(downloads): honor configured artifact storage

* fix(downloads): recover proxy-observed artifact misses

* test(downloads): isolate remote cleanup assertions

* fix(downloads): bound remote artifact recovery

* fix(downloads): allow concurrent artifact relays

* fix(downloads): harden distributed artifact recovery
2026-08-12 15:14:08 -04:00
QuickandGitHub 9c69760106 feat(downloads): distribute preparation and delivery (#607)
* feat(downloads): distribute preparation and delivery

* fix(downloads): harden distributed job routing

* fix(downloads): release probe capacity and bound tracking
2026-08-12 15:08:51 -04:00
QuickandGitHub 24c3feeb64 fix(playback): restore Safari Dolby Vision Profile 8 remux (#613)
* fix(playback): restore Safari Dolby Vision remux

* fix(playback): bound web Dolby Vision claims

* fix(web): scope Dolby Vision output probes

* fix(playback): constrain web Dolby Vision claims

* fix(web): refresh playback after output changes

* fix(playback): preserve output replan intent

* fix(playback): model output capability changes

* fix(web): disarm paused HLS startup guard

* fix(web): probe progressive HDR10 support

* fix(playback): preserve queued output state

* fix(playback): bound output refresh evidence

* fix(playback): preserve viable routes across replans

* fix(playback): carry output evidence through queued replans
2026-08-12 14:15:49 -04:00
QuickandGitHub e36612e619 feat(web): improve desktop user settings layout (#615)
- Reorganize settings into task-focused groups with uniform cards
- Move search into the page header and refine desktop detail navigation
- Remove redundant category jump links
2026-08-12 10:44:03 -04:00
QuickandGitHub 75936cdae2 fix(web): show profile avatar in mobile settings link (#614)
- Preserve the profile initial fallback when no avatar is set
- Add an accessible settings label and avatar coverage
2026-08-12 09:34:55 -04:00
QuickandGitHub 1dcdd4b27a fix(web): dedupe multi-library collection options (#601) 2026-08-11 21:12:13 -04:00
QuickandGitHub 2dc7d5e36c fix(playback): honor device audio language selection (#596)
* fix(playback): honor device audio language selection

* fix(playback): harden device preference retries

* fix(playback): preserve device-aware retries
2026-08-11 16:19:30 -04:00
QuickandGitHub 461b51c02c fix(playback): restore V3 subtitle timing and stability (#598)
* fix(playback): restore copy remux timeline anchors

* fix(web): stop refused subtitle replan loops

* fix(web): render embedded text subtitles as sidecars

* fix(web): preserve signed audiobook timeline offsets

* fix(playback): harden V3 remux and subtitle recovery

* fix(playback): anchor audio-only remux timelines
2026-08-11 15:26:07 -04:00
Quick104 6dd3f1a4ae Merge Docker Compose quick-start cleanup 2026-08-11 13:03:06 -04:00
Quick104 b8500c33ca fix(docker): simplify default Compose setup 2026-08-11 13:02:09 -04:00
b25ddf8329 fix(web): defuse date time bomb in push-relay admin settings test (#589)
The fixture hardcoded push_relay_expires_at as 2026-08-10, so once the
calendar reached that date the component rendered the "Expired" branch
and the "renews automatically" assertion failed on every branch's CI.
Use a relative +30d expiration so the not-yet-expired branch stays
stable.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:16:19 -04:00
Puks The PirateandGitHub 20c6b146eb fix(jellycompat): repair episode handoff (#529)
Continue Watching clients can retain stale user and media-source values across episode transitions. Keep token-derived authorization authoritative and fall back to the requested item files.

Strip NUL code points across durable playback-session JSON so PostgreSQL persistence cannot leave negotiated sessions cache-only.
2026-08-11 12:15:59 -04:00
SuspenseandGitHub 6e105af256 fix(catalog): preserve access filters for match-any queries (#563)
* fix(catalog): preserve access filters for match-any queries

* test(sections): report seasonal fixture cleanup failures
2026-08-11 12:15:08 -04:00
d5a8cd5294 fix(chapterthumbs): add software HDR tone-map fallback (#576)
* fix(chapterthumbs): add software HDR tone-map fallback

* fix(chapterthumbs): tighten filter capability detection

* fix(chapterthumbs): harden tone-map fallback retries

* fix(chapterthumbs): coalesce filter probes

* fix(chapterthumbs): name disabled accelerator

* feat(chapterthumbs): add CPU tone-map toggle

* fix(config): reuse CPU tone-map setting key

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-11 12:13:06 -04:00
e77d9e933c fix(catalog): bound Next Up anchors by distinct series, not rows (#593)
* fix(catalog): bound Next Up anchors by distinct series, not rows

The global Next Up query capped its anchor scan at the 500 most recently
completed rows (nextUpAnchorMaxRows, #350). Bulk mark-watched writes
hundreds of completed rows with the newest timestamps, so one series
could flood the whole window and evict every other series from the rail
- observed in production wiping a user's Next Up row entirely.

Replace the row-capped CTE with a recursive skip-scan over
idx_uwp_profile_completed that emits the newest completed row of each
not-yet-seen series and stops after nextUpAnchorMaxSeries (96) distinct
series. A compound (updated_at, media_item_id) cursor keeps the walk
total when bulk writes share one timestamp across series. The
series-scoped branch (show-detail tile) keeps its unbounded shape.

DB-backed regression tests cover the flood shape (600 same-timestamp
rows of one series must not evict others) and same-timestamp anchors
across series; the flood test fails against the previous query.

Fixes #592

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): continue Next Up anchor walk until enough eligible series found

The 96-series walk budget counted visited series, but eligibility (next
episode exists with a present file, no newer partial progress) is only
decided downstream, so 96 consecutive ineligible anchors - all caught
up, unavailable, or blocked - still emptied the rail and silently capped
/Shows/NextUp pagination at whatever survived one walk.

Run the walk in batches: each batch keeps the 96-series budget, reports
its frontier (compound cursor position, seen-series array, rows walked),
and ListNextUp resumes the next batch below that frontier until the
requested limit is filled, history is exhausted (frontier short of the
budget), or a 10-batch runaway guard trips (logged, never silent).
Batches emit anchors in strictly descending (updated_at, media_item_id)
order, so appending preserves rail order. Series-scoped queries keep
their single-shot shape.

DB-backed regressions: 100 fully-watched series must not hide older
series with eligible next episodes (fails on the single-batch code), and
an all-caught-up profile returns empty without spinning to the batch
cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): anchor Next Up on the highest episode, not the highest ID

Review follow-up to the anchor walk. Two issues remained after the
batching fix.

The walk ordered anchors by (updated_at, media_item_id) and then by
(season, episode). media_item_id is unique, so no two rows ever tie on
that pair and the season/episode clause was unreachable. For a bulk
mark-watched series — every row written with one timestamp — the anchor
was therefore whichever content_id sorted highest. With production-shape
18-digit IDs where season 2 was scanned before a season-1 backfill,
season 1 sorts higher, so the rail surfaced s01e04: an episode following
one the user had already watched.

Choosing the series and choosing its anchor episode are now separate
steps. The walk still advances on the compound cursor, which is the total
order it needs; a lateral then picks the highest (season, episode) among
that series' rows sharing pick.updated_at, matching what the
series-scoped branch already did. Pinning updated_at instead of
re-sorting keeps it an index probe, and both rows carry the same
updated_at, so cursor order and the reported CompletedAt are unchanged.

Also adds the index the compound cursor needs. idx_uwp_profile_completed
stops at updated_at, so the media_item_id half of the seek was a filter
and every step re-read the rows tied on one timestamp — the exact shape a
bulk mark-watched profile has. Measured by dropping and recreating the
index around the shipped query on a synthetic 40k-row profile: 341ms ->
224ms, same plan shape otherwise.

Verified against Postgres 18.3. The new DB regression seeds the
scanned-out-of-order ID shape and fails on the previous ordering
(returns s01e04, wants s02e04); the SQL-shape test pins that
media_item_id can no longer gate the episode choice.

Part of #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:08:45 -04:00
QuickandGitHub 90bdbfeb84 fix(playback): expose conditional range outcomes (#594)
* fix(playback): expose conditional range outcomes

* fix(playback): classify rejected If-Range requests

* fix(playback): evaluate If-Range diagnostics directly
2026-08-11 10:31:39 -04:00
Tomislav FilipcicandGitHub c644f43a02 fix(playback): align default transcode directory (#573) 2026-08-11 10:20:40 -04:00
ceb3393992 fix(historyimport): use standard Jellyfin authorization header (#572)
Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com>
2026-08-11 10:19:56 -04:00
zZebrahzandGitHub 383a2cce71 fix(settings): restore box subtitle appearance default (#568) 2026-08-11 10:19:39 -04:00
QuickandGitHub 6b70edfa39 fix(deps): resolve Dependabot security alerts (#591)
* fix(deps): resolve Dependabot security alerts

* fix(reader): scope PDF.js 6 integration

* fix(reader): address PDF.js compatibility gaps
2026-08-11 09:59:30 -04:00
881c96864b feat(playback): finalize platform-neutral protocol v3 (#567)
* docs(playback): add v3 neutral-contract finalization plan

Supersedes the wire-contract sections of the 2026-07-12 v3 plan: server-owned
attempt keys, delivery-keyed negotiation without Media3 engine names, tiered
capability evidence, neutral device/output context, track/quality replan
operations, audio-only planning, and coordinated no-back-compat rollout
across server, Android, Apple, and web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): make v3 attempt keys server-owned and replace engines with deliveries

Contract core of the platform-neutral v3 finalization (plan sections 3.1
and 3.2), breaking on purpose — v3 is dark and all clients move together:

- Every PlanV3 now carries plan_attempt_key, an opaque server-computed
  token clients store and echo in attempted_plan_keys; ReplanRequestV3
  gains bounded local_mutations that the replan handler folds into the
  failed plan's key. Clients never hash anything.
- KotlinName() is deleted from DeliveryV3, StreamProtocolV3 and
  SubtitleModeV3; the attempt-key canonical string now uses lowercase
  wire tokens, and PlanRecipeVersionV3 bumps to v3.3 so no key or plan
  ID computed under the old canonicalization can collide.
- EngineV3 leaves the wire: ClientPlaybackContextV3.Engines (media3_*)
  becomes Deliveries keyed original_http|progressive|hls, with
  EngineCapabilityV3 renamed DeliveryCapabilityV3. PlanV3.Engine is
  removed; the planner, subtitle policy and quirk registry re-key on
  delivery class, and the media3_only feature token is deleted.
- Validated-claim strings drop the prefix: media3_h264_decode ->
  h264_decode, media3_audio_decode -> audio_decode.
- Golden fixtures in testdata/protocol_v3 are regenerated by Go and are
  now the cross-repo source of truth.

Part of the playback protocol v3 neutral-contract train (steps 2-3 of
docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): add v3 evidence tiers and neutral device/output context

Implement plan sections 3.3 and 3.4 of the v3 neutral-contract pass:

- ClientCodecCapabilitiesV3 gains required video_evidence and
  audio_evidence closed enums (exact | platform_attested | declared).
  Planner strictness follows the tier: exact keeps the strict decode-entry
  validation, platform_attested validates codec/resolution/bit-depth/
  frame-rate but skips profile/level matching, declared grants copy routes
  from the flat codec lists. Only exact audio evidence earns passthrough
  claims. The detailed_decode_capabilities feature token is deleted
  (subsumed by video_evidence=exact), and evidence-blocked direct routes
  carry the new evidence_insufficient_for_direct reason/warning.

- DeviceContextV3 is now platform/os_version/manufacturer/model plus a
  bounded platform_details map (<=16 entries, <=128 chars); the Android
  Build dump fields are gone. Fire TV quirks keep matching on
  manufacturer/model (brand fallback removed with the field).

- output_route_generation (int64, dual-location) becomes an optional
  opaque output_context_id string on the output context; the dual-location
  consistency validation is deleted. Attempt keys, plan invalidation,
  route events, and the planstore column follow (new Goose migration).

- Feature advertisement collapses to the top-level client_features list
  only; ClientPlaybackContextV3.Features is deleted and ReplanRequestV3
  gains an optional client_features refresh.

- PlanRecipeVersionV3 bumped v3.3 -> v3.4; fixtures re-keyed.

Part of the playback protocol v3 neutral-contract finalization plan
(docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): add v3 intent replans, quality menu, and audio-only routes

Protocol v3 could only replan after a failure, so changing the audio track
or the quality still required the legacy audio PATCH and the client-recipe
transcode start — the two endpoints v3 is meant to replace. Clients also had
to own a resolution ladder to render a quality menu, and a source with no
video track was terminaled by the video/HDR gates, keeping audiobooks on the
legacy path.

Add track_change and quality_change replan operations. They carry no failure
classification and route through the existing replan transaction, so they
inherit its idempotency, capacity reservation, and staged-successor commit
for free. Because nothing failed, the previous route stays eligible: neither
the attempted-key history nor the failed-plan exclusion applies to them.

Publish the server ladder on the plan as available_qualities so the quality
menu is server-owned; the rungs come from the same resolutionLabelV3 and
ladderBitrateKbpsV3 helpers the planner itself uses, not a parallel table.

Plan audio-only sources through their own reduced route family: original_http
when the client decodes the codec, otherwise a progressive AAC conversion.
The plan advertises audio/mp4 for that remux and the transport now serves the
same value, because a declared-tier client probes the advertised MIME with
isTypeSupported before attaching a source buffer, and "video/mp4" on a stream
with no video track is exactly the mismatch that makes the probe lie.

Name the protocol's string vocabulary (dynamic ranges, transformations,
executors, validated claims, terminal reasons) as constants while touching
these lines, so the wire values have one definition.

Part of #135

* docs(playback): publish the v3 protocol contract and fix subtitle ordinals

Protocol v3 exists only as Go code today, so the Android and Apple ports have
no authority to implement against other than reading this repository. Publish
the contract as a normative document, machine-checkable schemas, and generated
golden fixtures, and fix the one place where the server's own wire output
disagreed with the ordinal space it publishes.

- docs/architecture/playback-protocol-v3.md is self-contained enough for a
  third-party client: endpoints and status codes, evidence tiers and their
  bound-matching rules, delivery classes, the timeline model, replan
  semantics, registries, track identity, plan identity, quality, and
  transformations.
- docs/design/schemas/playback-v3/ carries JSON Schemas for the five wire
  shapes plus valid and invalid fixtures, following the client-diagnostics
  layout. internal/playback/contract validates every fixture against its
  schema, so a schema that drifts from the Go types fails the Go suite.
- cmd/playbackfixtures generates internal/playback/testdata/protocol_v3 from
  the production planner. `make playback-fixtures` writes them and
  `make verify-playback-fixtures` (wired into CI) fails when they are stale.
  These files are what the client ports consume, so drift would otherwise
  surface as a playback bug on three platforms at once.

The subtitle fix: combined ordinals are one dense space over externals, then
embedded tracks, then downloaded ones, but the legacy URL builder skipped
burn-in-only tracks while assigning indices, so every track after a DVD/DVB
track was numbered one too low and resolved to its neighbour. Ordinal
assignment now lives in playback.BuildSubtitleInventoryV3 and both the plan
inventory and the legacy `subtitle_urls` shape project from it; the legacy
shape still filters burn-in-only entries but keeps each track's real index.

Part of #135

* feat(web): migrate the players to the neutral playback v3 contract

The web player was the last client still speaking the legacy start
protocol: it picked its own file version from a codec probe, posted an
ffmpeg recipe to start a transcode, PATCHed an endpoint to change audio
tracks, and derived its own quality ladder. None of that survives a
server-owned plan, and none of it produced telemetry the apps could be
compared against.

Video player: starts with a v3 request that advertises `declared`
evidence from `isTypeSupported` probes and the three delivery classes,
then consumes the returned plan for its URL, timeline, tracks and
warnings. Quality and track changes become replans (`quality_change`,
`track_change`), the quality menu renders `available_qualities` instead
of computing rungs, and playback failures emit `route-events` so web
failures land in the same diagnostics as Android and Apple. The
duration comes from `source.duration_seconds` rather than the playback
engine, and the "how was this delivered" overlay reads the plan's
delivery and server transformations instead of comparing codec strings.

Audiobook player: starts against the audio-only planner path with a
single `original` rung, and takes its seek anchor from
`timeline.player_start_seconds` so the progressive-remux route (which
anchors the stream and restarts the player clock at zero) does not seek
twice.

Server side, `disable_progress_persistence` left the wire, so the rule
it encoded is now derived. Resume state is keyed on the item, but every
part of a multipart presentation shares that key while carrying its own
file-local clock — persisting part 4's position would store "12 minutes
in" as the book's resume point. `PresentationPartTotal > 1` expresses
that directly and generalizes to multipart movies and split episodes,
and a client can no longer forget to ask or lie about it.

`useTranscodeQuality` and the legacy response types are deleted, and
`WEBTEST_KNOWN_FAILURES` loses the audiobook entry along with its fix.

Part of #135

* feat(playback)!: make v3 the only playback protocol

Protocol v3 shipped behind a flag, alongside the legacy start path it was
designed to replace. Running both meant every planner change had to be made
twice, in two shapes that disagree about who decides the route: the legacy
body carried a decision the client had already made, while v3 asks the server
to make it. This deletes the legacy half.

Removed:

- `handleStartPlaybackLegacy` and its request/response bodies. The
  `POST /playback/start` route stays, but the protocol-version dispatch
  envelope is now a strict v3 decode — a body that does not declare
  `protocol_version: 3` gets `426 client_upgrade_required` so an outdated app
  can render a clear "update required" state instead of misreading a plan.
  Deliberately not a `400`: the request may be well-formed for the protocol it
  was written against.
- `POST /playback/transcode/start`, superseded by the `quality_change` replan
  operation, and `PATCH /playback/{session_id}/audio`, superseded by
  `track_change`. Both mutated a session without re-planning.
- The shadow planner and both rollout settings rows. With v3 the only
  protocol, `playback.protocol_v3_enabled` would mean "no playback at all";
  `playback.protocol_v3_shadow_enabled` gated a comparison against a path that
  no longer exists. `409 protocol_disabled` on route-events goes with them, and
  capability `enabled` is now constant `true` (the field stays — clients
  feature-detect against it).
- Version-selection helpers in `internal/playback/resolver.go` that only legacy
  start reached. `Resolve`/`ClientCapabilities`/`PlayDecision` stay: downloads
  consumes them. `internal/jellycompat` has its own resolution surface and is
  untouched.

Behaviour the legacy handlers owned and v3 now owns explicitly: series version
and audio-track preferences are persisted on start and on a `track_change`
replan (not on failure recovery, whose forced route is not a user choice); an
omitted `start_position` resolves to the profile's saved resume point; and an
omitted audio track resolves through the series preference, the profile audio
language, then the library override. Both are settled before planning, because
the plan's timeline is cut at the start position. Spec §2.2 documents this as
"omission is a request, not a default".

The encode-target clamp that lived in the deleted transcode handler is already
enforced in the planner, twice — `availableQualitiesV3` omits rungs at or above
the source height, and the encode path clamps `targetHeight` to it.

Unchanged: progress, stop, HLS manifest and segment delivery, the realtime
control socket, stream tokens and restart reconstruction, watch together,
downloads, jellycompat.

Every removal is recorded in the pre-lock removals table in
docs/architecture/v1-scope.md.

Part of #135

* fix(scanner): stop recording embedded cover art as a video track

ffprobe reports embedded cover art as a video stream carrying
disposition.attached_pic. convertProbeData appended every "video" stream
to VideoTracks without consulting isMainVideoStream, the predicate that
already existed for duration decisions, so the picture was persisted as a
playable track. That misreports the file twice:

  - An audio file with a cover picks up a video track, so it no longer
    satisfies MediaFile.IsAudioOnly and the v3 planner routes an
    audiobook through the video path instead of planAudioOnlyV3.
  - When the picture is ordered ahead of the real stream, the flat
    codec_video/resolution/hdr columns describe the poster: a 954x720
    h264 episode was stored as mjpeg 480x480.

Filter attached_pic streams out of the track loop. The guard is the
disposition flag, not the codec name, so a genuine MJPEG video is still
probed as video — the library has one.

Already-probed rows self-heal on the next playback: NeedsCriticalProbeRepair
already reprobes tracks missing color_range, which covers 21 of the 23
affected rows, and applyProbeData overwrites VideoTracks wholesale. The
remaining two need a rescan; nothing persisted records attached_pic, and
keying repair off still-image codec names would reprobe the genuine MJPEG
file on every playback forever.

Part of the playback v3 neutral-contract work: it is what lets Android
drop AUDIOBOOK_COVER_ART_CODECS, which fabricated decode support the
client cannot honestly claim under video_evidence: "exact".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(playback): publish subtitle URLs even when playback starts with subtitles off

The v3 plan's subtitle inventory is the authoritative track list a client builds
its subtitle menu from, but the handler only rewrote it with session-scoped URLs
when a track was actually selected. A start or replan that resolved to
`subtitle.mode: "off"` therefore returned the planner's URL-less inventory, so a
client whose picker reads the inventory had a menu it could not fetch anything
from. The Cast path hits this every time: it starts with subtitles off and needs
the receiver's text tracks up front.

attachSubtitleArtifactV3 now scopes and publishes the inventory unconditionally
and gates only the artifact stamping on the selection. Spec §8 records that the
`url` on a sidecar entry does not depend on the current selection.

Part of the v3 neutral-contract finalization.

* chore(playback): reconcile neutral v3 with main

* fix(playback): preserve subtitle intent across replans

* fix(playback): retain subtitle inventory on adapted routes

* fix(playback): software-decode High10 AVC for QSV

* fix(playback): scale High10 frames before QSV upload

* fix(playback): preserve empty subtitle inventories

* fix(playback): freeze terminal attempt contract

* chore(playback): name fixture contract tokens

* fix(playback): close v3 conformance review gaps

* chore(playback): name conformance category

* fix(playback): complete v3 conformance contract

* fix(playback): keep schema fixtures generated

* fix(playback): emit schema-valid conformance arrays

* fix(playback): omit empty replan failures

* fix(web): omit empty replan failures

* fix(playback): close neutral v3 contract gaps

* fix(playback): harden v3 replan, transcode, and quality-ladder edge cases

Review remediation for the neutral v3 cutover, server side:

- A failed replan no longer overwrites the durable StartResponse with a
  terminal or advances the replan request ID; an idempotent start replay
  of a still-healthy session returns the original plan.
- SoftwareVideoDecode is now derived inside the transcode layer from
  source facts (codec/profile/bit depth) carried on TranscodeOpts, so
  jellycompat, downloads, recipe-card reconstruction, and transcode
  nodes get the High10 software-decode fix, not just the v3 handler.
  video_to_h264 recipe version bumps to 2 so mixed-version node pools
  that would silently drop the flag fail validation instead.
- Local transport startup shares the 30s ManifestStartupTimeout; a
  timeout with the process still running stays retryable and is no
  longer persisted as a durable terminal against the attempt.
- Sparse replan bodies (failure_recovery et al) no longer reset a
  user-selected quality preference to auto; the empty-value guard now
  covers every operation.
- availableQualitiesV3 publishes no fixed rungs when the source height
  is unknown, keeping the no-upscaling ladder contract.
- The proxy remux path serves audio-only fMP4 as audio/mp4 via a new
  additive AudioOnly token claim, matching the integrated path.
- Plain text subtitle sidecars accept any requested extension again
  (served as VTT), restoring the permissive v1 behavior; ASS and bitmap
  handling is unchanged.
- The 4K-disallowed terminal message discloses when a lower-resolution
  alternate exists but was pinned away by quality "original".

Part of #135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): keep playback alive through failed replans and honest audio claims

Review remediation for the neutral v3 cutover, web player:

- A failed or refused replan no longer unmounts the player: the fatal
  error screen is reserved for loads with no adopted plan, and replan
  failures surface through the existing non-fatal replanError path.
- changeQuality rolls its optimistic preference back when the replan is
  refused or errors, so a failed switch is not silently applied by the
  next unrelated replan and the menu shows the real active rung.
- The capability probe now tests mp3/vorbis codecs and mp3/flac/ogg
  containers (MediaSource with a canPlayType fallback), restoring
  direct play for mp3 audiobooks instead of per-part AAC re-encodes.
- Reanchor seeks issued while a replan is in flight coalesce and run
  when it settles instead of being silently dropped with the scrubber
  pinned to a phantom position.
- Subtitle refresh/translation replans use the resume anchor while the
  media element has no metadata, so a subtitle_ready broadcast during
  startup no longer restarts a resumed stream at 0:00.
- An exhausted failure-recovery chain sets a visible error instead of
  returning silently.

Part of #135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): accept video-only and VP9 probe metadata

Treat audio and video probe completeness independently so legitimate video-only assets converge without repeated ffprobe repair. Allow unknown codec profile/level metadata to fall through to server adaptation while preserving exact direct-decode constraints.

Fixes #574

* fix(playback): address protocol v3 review findings

* fix(playback): harden lease and probe repair decisions

* fix(playback): close remaining v3 review gaps

* fix(playback): recover failed transcode starts

* fix(playback): address remaining review-bot findings on v3 replan and audio planning

Server:
- The deferred replan lease release is bounded by a 3s timeout so a
  saturated pool or DB outage cannot wedge a handler goroutine that
  holds the per-session store lock on an uncancellable context.
- planAudioOnlyV3 honors the request bandwidth cap: an over-cap source
  skips the original_http direct route and converts to AAC with the
  same bandwidth_cap_applied warning and decision reason the video
  ladder uses. Unknown source bitrate never triggers the cap.
- A copy-audio progressive plan rejected only by a per-delivery
  audio_decode_codecs subset retries as an AAC conversion instead of
  returning adaptation_unavailable, and the AAC recipe respects the
  delivery's max_channels.

Web:
- failure_recovery replans issued while another replan is in flight
  queue (superseding a pending seek reanchor) instead of being
  silently dropped with the fatal overlay already suppressed.
- A terminal response to a fresh non-preserving start clears the
  previous plan and stops its session, so episode navigation cannot
  keep rendering the prior item under the new title.
- A refused recovery replan for a transport-dead plan surfaces the
  error and re-arms the plan failure key, so transient recovery
  failures no longer strand an endless spinner; the audiobook player
  gets the same guard reset.
- A track-less subtitle_translation_completed hands off to the
  refreshed persisted track once the inventory settles, clearing the
  live overlay, instead of pinning the synthetic live track forever.

Part of #135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): reuse HLS transport for sidecar replans

* fix(playback): stabilize copy HLS remount timeline

* fix(playback): address v3 review findings

* fix(playback): satisfy player contract types

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 18:14:49 -04:00
QuickandGitHub fe11b2601d fix(jellycompat): default server name to Silo (#553) 2026-08-08 11:22:27 -04:00
QuickandGitHub 39efe308af fix(markers): stop retrying contribution conflicts (#559)
* fix(markers): stop retrying contribution conflicts

* fix(markers): claim contributions atomically

* fix(markers): recover stale contribution claims
2026-08-07 11:21:26 -04:00
424 changed files with 49362 additions and 11781 deletions
+16 -4
View File
@@ -2,9 +2,11 @@
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
# Linux/macOS example for enabling NVIDIA override:
# Linux example for Intel/AMD VA-API or Intel Quick Sync:
# COMPOSE_FILE=docker-compose.yml:docker-compose.vaapi.yml
# Linux example for NVIDIA NVENC:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows example:
# Windows NVIDIA example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
@@ -27,6 +29,15 @@ POSTGRES_USER=silo
POSTGRES_PASSWORD=silo
POSTGRES_DB=silo
POSTGRES_SHM_SIZE=8gb
# POSTGRES_PORT=5432
# REDIS_PORT=6379
# Optional Meilisearch service. Generate a key, uncomment it, then start the
# search profile with: docker compose --profile search up -d
# MEILI_MASTER_KEY=replace-with-output-of-openssl-rand-hex-32
# MEILISEARCH_IMAGE=getmeili/meilisearch:latest
# MEILISEARCH_HOST=127.0.0.1
# MEILISEARCH_PORT=7700
# PostgreSQL auto-tuning is enabled by default in docker-compose.yml. Silo
# applies pgtune-style OLTP recommendations through ALTER SYSTEM at startup.
@@ -40,7 +51,8 @@ POSTGRES_SHM_SIZE=8gb
# POSTGRES_TUNE_DB_SIZE=auto
# POSTGRES_TUNE_CONNECTIONS=100
# Optional host port overrides for docker-compose.yml.
# Optional host port overrides for docker-compose.yml. The container listeners
# remain fixed, so these values only change the host side of each port mapping.
# PORT=8090
# Used only after Jellyfin-compatible app support is enabled by an admin.
# JF_PORT=8096
@@ -77,7 +89,7 @@ POSTGRES_SHM_SIZE=8gb
# Optional Redis override when running from source or pointing workers at an external Redis.
# REDIS_URL=redis://localhost:6379
# Optional standalone worker identity settings for docker-compose.remote-transcode.yml.
# Optional standalone worker identity settings for custom worker deployments.
# NODE_NAME=transcode-01
# NODE_URL=http://transcode-01.example.com:8082
+8
View File
@@ -99,6 +99,14 @@ jobs:
- name: Verify settings bindings are current
run: make verify-settings-bindings
# The server defines the playback protocol and the clients prove
# conformance against these fixtures. A contract change that does not
# regenerate leaves every client testing against a stale body, so the
# drift surfaces as a playback bug on three platforms rather than as a
# failure here.
- name: Verify playback protocol fixtures are current
run: make verify-playback-fixtures
# Runs the settings-contract gate among everything else: the embedded
# manifest must parse, satisfy its own schema, hold every structural
# invariant, and agree with the live settings registry on keys and
+1
View File
@@ -82,3 +82,4 @@ docker-compose.local.yml
.playwright-cli/
output/
/settingsgen
/playbackfixtures
+16 -7
View File
@@ -6,9 +6,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge requ
## Prerequisites
- **Go** 1.24+
- **Bun** 1.0+
- **PostgreSQL** 18+
- **Go** 1.26.4+
- **Node.js** 22+ with **pnpm** 10.32.1
- **PostgreSQL** 18 with pgvector
- **Redis**
- **FFmpeg** (for transcoding support)
## Local Development
@@ -16,6 +17,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge requ
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) for local services and the source-build workflow below.
```sh
# Create the local bootstrap configuration
cp .env.example .env
printf '\nSECRET_KEY=%s\nDATABASE_URL=%s\nREDIS_URL=%s\n' \
"$(openssl rand -base64 48)" \
'postgres://silo:silo@localhost:5432/silo?sslmode=disable' \
'redis://localhost:6379' >> .env
# Start local PostgreSQL and Redis
docker compose up -d postgres redis
@@ -26,7 +34,8 @@ make dev-frontend
make dev-backend
```
The main compose file expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first (`cp .env.example .env`).
The template supplies a non-empty `MEDIA_ROOT` because Compose validates the whole file even when
you start only PostgreSQL and Redis. Change it before testing libraries against real media.
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
@@ -79,7 +88,7 @@ database URL should be read from a non-default env file.
go test ./...
# Frontend tests
cd web && bun test
cd web && pnpm test
```
## Linting
@@ -89,8 +98,8 @@ cd web && bun test
golangci-lint run
# Frontend
cd web && bun run lint
cd web && bun run format:check
cd web && pnpm run lint
cd web && pnpm run format:check
```
## Project Structure
+31 -2
View File
@@ -1,4 +1,4 @@
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up migrate-down-to settings-bindings verify-settings-bindings verify-settings-bindings-web verify-settings-bindings-all
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up migrate-down-to settings-bindings verify-settings-bindings verify-settings-bindings-web verify-settings-bindings-all playback-fixtures verify-playback-fixtures
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
@@ -62,7 +62,6 @@ WEBTEST_KNOWN_FAILURES := \
--exclude src/pages/Catalog.test.tsx \
--exclude src/pages/ItemDetail/SeasonContent.test.tsx \
--exclude src/pages/LibraryRecommended.test.tsx \
--exclude src/pages/audiobooks/player/useAudiobookPlayback.test.ts \
--exclude src/pages/setup-wizard/steps/ServerStorageStep.test.tsx \
--exclude src/player/hooks/useASSSubtitles.test.tsx
@@ -145,6 +144,36 @@ verify-settings-bindings-web:
verify-settings-bindings-all: verify-settings-bindings verify-settings-bindings-web
# Regenerate the protocol-v3 golden contract fixtures from the live types and planner.
#
# The server owns the playback contract and the clients prove conformance
# against these bodies, so they are only trustworthy while the code that emits
# them is the code that serves traffic. Editing one by hand instead of running
# this would let the contract and the implementation drift apart in silence.
PLAYBACK_FIXTURE_DIR := internal/playback/testdata/protocol_v3
PLAYBACK_SCHEMA_FIXTURE_DIR := docs/design/schemas/playback-v3/v3/fixtures/valid
PLAYBACK_WIRE_FIXTURES := start_request.json replan_request.json decision_response.json capability_response.json error_response.json route_event.json
playback-fixtures:
go run ./cmd/playbackfixtures -out $(PLAYBACK_FIXTURE_DIR)
@set -e; for fixture in $(PLAYBACK_WIRE_FIXTURES); do \
cp "$(PLAYBACK_FIXTURE_DIR)/$$fixture" "$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture"; \
done
# Fail when the committed fixtures disagree with the contract types. A change
# that does not regenerate leaves every client testing against a body the server
# no longer produces, which is exactly the drift the fixtures exist to catch.
verify-playback-fixtures:
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
go run ./cmd/playbackfixtures -out "$$CHECK_DIR" && \
diff -ur $(PLAYBACK_FIXTURE_DIR) "$$CHECK_DIR" \
|| { echo "::error::$(PLAYBACK_FIXTURE_DIR) is stale; run make playback-fixtures"; exit 1; }; \
for fixture in $(PLAYBACK_WIRE_FIXTURES); do \
cmp -s "$$CHECK_DIR/$$fixture" "$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture" \
|| { echo "::error::$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture is stale; run make playback-fixtures"; exit 1; }; \
done
@echo "playback fixtures are current"
# Check committed content for local machine path leaks.
verify-local-paths:
scripts/check-local-path-leaks.sh
+77 -26
View File
@@ -15,14 +15,22 @@ Join the community on [Discord](https://discord.gg/4RxuUQAEnW). If Silo is usefu
## Deploy with Docker (recommended)
The easiest way to run Silo is with Docker Compose. The default stack assumes you do not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the application for a one-command start.
The easiest way to run Silo is with Docker Compose 2.24 or newer. The default stack assumes you do
not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the
application for a one-command start.
1. **Create a `.env` file**
```sh
cp .env.example .env
printf '\nPOSTGRES_PASSWORD=%s\nSECRET_KEY=%s\n' \
"$(openssl rand -hex 24)" "$(openssl rand -base64 48)" >> .env
```
This replaces the development database password from `.env.example` and creates the key Silo
uses to encrypt stored credentials. Back up `.env` separately from PostgreSQL; losing
`SECRET_KEY` makes those credentials unrecoverable.
2. **Set your media path**
Edit `.env` and set:
@@ -43,6 +51,21 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
### Optional Intel/AMD VA-API or Intel Quick Sync
The default stack is CPU-only so it starts on hosts without `/dev/dri`. On a Linux host with
`/dev/dri`, enable the device overlay:
```sh
docker compose -f docker-compose.yml -f docker-compose.vaapi.yml up -d
```
To make that the default for this installation, set:
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.vaapi.yml
```
### Optional NVIDIA/NVENC
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
@@ -78,32 +101,55 @@ By default, data is stored under `/opt/silo`:
- `/opt/silo/postgres`
- `/opt/silo/redis`
- `/opt/silo/plugins`
- `/opt/silo/compat`
- `/opt/silo/transcode`
- `/opt/silo/catalog-seeds`
The optional `search` profile also stores its index under `/opt/silo/meilisearch`.
Media is mounted into the container at `/mnt/media` from the host path you set in `MEDIA_ROOT`.
### Optional Profiles
### Optional Search Profile
The main compose file is integrated-first. These profiles exist for operators testing distributed mode or mirroring a split deployment shape. Most single-host installs should stay on the default integrated service, because it already includes proxying and transcoding.
PostgreSQL full-text search works without any optional services. Meilisearch is available when you
want its search provider:
| Profile | Command | Description |
|---|---|---|
| default | `docker compose up -d` | Integrated server plus bundled PostgreSQL and Redis |
| `proxy` | `docker compose --profile proxy up -d` | Start a standalone proxy service for distributed-mode testing |
| `transcode` | `docker compose --profile transcode up -d` | Start a standalone transcode service for distributed-mode testing |
| `search` | `docker compose --profile search up -d` | Add the optional Meilisearch service |
You can enable both optional examples together:
Before starting the `search` profile, set `MEILI_MASTER_KEY` in `.env` to the output of
`openssl rand -hex 32`. After Silo starts, choose Meilisearch under **Admin > Settings > Search**,
set the URL to `http://meilisearch:7700`, enter the same key as the API key, test the connection,
and save. Restart Silo, then rebuild the catalog search index from the same page. Silo continues
to use PostgreSQL full-text search until you select Meilisearch.
```sh
docker compose --profile proxy --profile transcode up -d
```
### Distributed Examples
If you are splitting workers across multiple hosts, use the separate remote worker example instead of trying to stretch the main compose file across machines.
The main Compose file includes commented proxy and transcode service examples. Most single-host
installs should leave them commented because the integrated service already includes proxying and
transcoding.
### Advanced Remote Node Example
Multi-host operators can use those examples as a starting point for a dedicated worker Compose
file connected to the deployment's shared PostgreSQL and Redis services.
For a dedicated remote transcode worker, use [docker-compose.remote-transcode.yml](docker-compose.remote-transcode.yml). That file is intended for a separate worker host that connects back to an existing Silo deployment using shared PostgreSQL and Redis.
Proxy nodes serve source downloads from the same absolute media paths used by direct playback.
Prepared-download work can also run on transcode nodes. Each selected transcode node retains its
result on node-local storage and exposes it only through Silo's authenticated internal artifact API;
the paired proxy relays those bytes, so no shared artifact mount is required. Dedicated transcode
nodes default to retaining prepared downloads in a protected directory inside the transcode volume
captured at process startup. `download.artifact_dir` overrides that location for both dedicated
transcode nodes and the integrated/API-local fallback, so mount the configured path on every process
that prepares downloads. Changing either artifact-path setting requires a restart. Downloads with a
configured server-wide or per-user bandwidth limit remain API-local so those aggregate limits stay exact.
Clients discover distributed delivery through `proxy_delivery` on the download capability response.
When it is true, they may opt into `GET` or `HEAD /api/v1/downloads/{id}/file-proxy` and
`/api/v1/direct-download-proxy`; those routes may return a temporary redirect to a proxy node. The
established `/file` and `/direct-download` routes keep serving bytes directly with their existing
status-code contract; for a node-local prepared artifact, the API itself performs the authenticated
relay on that fallback route.
### Deployment Notes
@@ -116,7 +162,10 @@ helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/co
## Configuration
Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch.
Silo requires `DATABASE_URL` and `SECRET_KEY` when running from source or against external
infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs
for you. All other settings — libraries, metadata providers, transcoding, users — are managed
through the admin UI after first launch.
### Server Modes
@@ -125,7 +174,7 @@ Silo requires only a `DATABASE_URL` when running from source or against external
| `integrated` | Full server: API + frontend + scanner + transcode (default) |
| `api` | API server only, no local transcoding |
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
| `transcode` | HLS and prepared-download worker node that connects to the shared deployment database and Redis |
### PostgreSQL Auto-Tuning
@@ -183,24 +232,26 @@ move fully to a custom `postgresql.conf`.
If you prefer running Silo without Docker:
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
1. **Install prerequisites**: Go 1.26.4+, Node.js 22+, pnpm 10.32.1, PostgreSQL 18 with pgvector, Redis, and FFmpeg.
2. **Start PostgreSQL and Redis** (skip if you already have them running)
2. **Configure the source process**
```sh
cp .env.example .env
printf '\nSECRET_KEY=%s\nDATABASE_URL=%s\nREDIS_URL=%s\n' \
"$(openssl rand -base64 48)" \
'postgres://silo:silo@localhost:5432/silo?sslmode=disable' \
'redis://localhost:6379' >> .env
```
Change the URLs when you use existing services instead of the bundled development defaults.
3. **Start PostgreSQL and Redis** (skip if you already have them running)
```sh
docker compose up -d postgres redis
```
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
3. **Configure the database connection**
```sh
cp .env.example .env
```
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
4. **Build and run**
```sh
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+863
View File
@@ -0,0 +1,863 @@
// Command playbackfixtures writes the protocol-v3 golden contract fixtures
// from the live Go types and planner.
//
// The direction of authority is inverted from where it started: the server
// defines the playback protocol, and clients prove conformance against these
// files. Android and Apple CI vendor them and compare their own decoders and
// opaque-token echo behavior against the values here, so a fixture is
// only trustworthy if it was produced by the same code that serves real
// traffic — hand-editing one would let the contract and the implementation
// drift apart silently.
//
// Usage:
//
// go run ./cmd/playbackfixtures -out internal/playback/testdata/protocol_v3
//
// `make playback-fixtures` runs it; `make verify-playback-fixtures`
// regenerates into a temporary directory and diffs, so a contract change
// cannot merge without refreshing what every client tests against.
package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
// goldenSessionID is a fixed UUID: fixtures must be byte-stable across runs,
// so nothing in this generator may read the clock or a random source.
const (
goldenSessionID = "11111111-1111-4111-8111-111111111111"
goldenAttemptID = "attempt-golden-0001"
goldenExpiresAt = "2030-01-01T00:00:00Z"
goldenMediaFileID = 42
// Codec and container tokens the fixtures are built from. Named so the
// capability lists, the plan recipe, and the source descriptor cannot drift
// into describing different media by a one-character typo.
codecH264 = "h264"
codecHEVC = "hevc"
codecAAC = "aac"
codecTrueHD = "truehd"
containerMP4 = "mp4"
containerMKV = "mkv"
containerHLS = "hls"
profileHigh = "high"
resolutionHD = "720p"
resolutionFHD = "1080p"
qualityOriginal = "original"
audioLayoutStereo = "stereo"
audioLayout71 = "7.1"
filmFrameRate = "24000/1001"
languageEnglish = "eng"
videoRangeSDR = "SDR"
categoryMidSeek = "mid_seek_replan"
)
func main() {
out := flag.String("out", "", "directory to write fixtures into (required)")
flag.Parse()
if *out == "" {
fail("usage: playbackfixtures -out <dir>")
}
if err := os.MkdirAll(*out, 0o755); err != nil {
fail("create %s: %v", *out, err)
}
write(*out, "start_request.json", goldenStartRequest())
write(*out, "replan_request.json", goldenReplanRequest())
write(*out, "decision_response.json", goldenDecisionResponse())
write(*out, "capability_response.json", goldenCapabilityResponse())
write(*out, "error_response.json", goldenErrorResponse())
write(*out, "route_event.json", goldenRouteEvent())
write(*out, "subtitle_inventory.json", goldenSubtitleInventory())
write(*out, "attempt_keys.json", goldenAttemptKeys())
write(*out, "conformance_matrix.json", goldenConformanceMatrix())
}
func goldenErrorResponse() playback.ErrorResponseV3 {
return playback.LegacyUpgradeErrorV3()
}
func write(dir, name string, value any) {
body, err := json.MarshalIndent(value, "", " ")
if err != nil {
fail("marshal %s: %v", name, err)
}
body = append(body, '\n')
path := filepath.Join(dir, name)
if err := os.WriteFile(path, body, 0o644); err != nil { //nolint:gosec // generated fixture
fail("write %s: %v", path, err)
}
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
// goldenCapabilities is the exact-evidence Android-shaped capability block the
// request fixtures share. Both request bodies carry the same capability
// contract, so they are built from one function rather than two drifting
// literals.
func goldenCapabilities() playback.ClientCodecCapabilitiesV3 {
return playback.ClientCodecCapabilitiesV3{
VideoEvidence: playback.EvidenceExactV3,
AudioEvidence: playback.EvidenceExactV3,
CodecsVideo: []string{codecH264},
CodecsVideoHardware: []string{codecH264},
CodecsAudio: []string{codecAAC},
Containers: []string{containerMP4},
MaxResolution: resolutionFHD,
VideoDecode: []playback.VideoDecodeCapabilityV3{{
Codec: codecH264,
Profiles: []string{profileHigh},
Levels: []int{41},
BitDepths: []int{8},
MaxWidth: 1920,
MaxHeight: 1080,
MaxFrameRate: 60,
MaxBitrateKbps: 20_000,
Hardware: true,
}},
}
}
func goldenPlaybackContext() playback.ClientPlaybackContextV3 {
return playback.ClientPlaybackContextV3{
ProtocolVersion: playback.ProtocolV3,
FormFactor: "tv",
AppVersion: "3.0-test",
Device: playback.DeviceContextV3{
Platform: "android",
OSVersion: "15",
Manufacturer: "NVIDIA",
Model: "SHIELD Android TV",
// Everything platform-specific travels here as opaque bounded
// strings; the contract itself stays neutral.
PlatformDetails: map[string]string{"sdk_int": "35", "abis": "arm64-v8a"},
},
Output: playback.OutputContextV3{OutputContextID: "7"},
Deliveries: map[string]playback.DeliveryCapabilityV3{
playback.DeliveryClassOriginalHTTPV3: {
Enabled: true,
SupportedOnDevice: true,
Containers: []string{containerMP4},
VideoCodecs: []string{codecH264},
AudioDecodeCodecs: []string{codecAAC},
AudioPassthroughCodecs: []string{},
Subtitles: playback.DeliverySubtitleCapabilitiesV3{
EmbeddedText: true,
SidecarText: true,
},
Features: []string{},
AuthHeaderRefresh: true,
ValidatedClaims: []string{},
Transformations: []playback.TransformationV3{},
},
},
}
}
func goldenStartRequest() playback.StartRequestV3 {
start := 12.5
audioIndex := 0
return playback.StartRequestV3{
ProtocolVersion: playback.ProtocolV3,
ClientFeatures: []string{playback.FeaturePlaybackPlanV3},
FileID: goldenMediaFileID,
ProfileID: "profile-1",
PlaybackAttemptID: goldenAttemptID,
QualityPreference: playback.QualityOriginalV3,
SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
StartPosition: &start,
ProgressPersistence: playback.ProgressPersistenceClientV3,
AudioTrackID: playback.TrackIDV3(goldenMediaFileID, "audio", 0),
AudioTrackIndex: &audioIndex,
Capabilities: goldenCapabilities(),
ClientPlaybackContext: goldenPlaybackContext(),
}
}
func goldenReplanRequest() playback.ReplanRequestV3 {
estimate := 3_500
cap := 4_000
audioIndex := 0
decision := goldenDecisionResponse()
if decision.PlaybackPlan == nil {
fail("golden decision has no plan")
}
plan := decision.PlaybackPlan
return playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
ClientFeatures: []string{playback.FeaturePlaybackPlanV3},
Operation: playback.ReplanOperationFailureRecoveryV3,
PlaybackAttemptID: goldenAttemptID,
ReplanRequestID: "replan-golden-0001",
FailedPlanID: plan.PlanID,
PlanAttemptID: "plan-attempt-golden-0001",
// Attempt keys are server-owned opaque tokens. A client echoes the
// values it was handed; it never computes one.
PlanAttemptKey: plan.PlanAttemptKey,
AttemptedPlanKeys: []string{plan.PlanAttemptKey},
AttemptCount: 1,
QualityPreference: "auto",
PositionSeconds: 42.5,
Metered: true,
BandwidthEstimateKbps: &estimate,
BandwidthCapKbps: &cap,
SelectedTracks: playback.SelectedTracksV3{
Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(goldenMediaFileID, "audio", 0), Index: &audioIndex},
},
Failure: playback.FailureV3{Classification: "network_degraded"},
Capabilities: goldenCapabilities(),
ClientPlaybackContext: goldenPlaybackContext(),
}
}
// goldenMediaFile is the subtitle-bearing source the inventory fixtures
// describe. The embedded track list deliberately mixes a text track, a PGS
// track, and a DVD bitmap track: the DVD track has no sidecar shape the stream
// handler can serve, and pinning its ordinal is the point of the fixture.
func goldenMediaFile() *models.MediaFile {
return &models.MediaFile{
ID: goldenMediaFileID,
ExternalSubtitles: []models.ExternalSubtitle{
{Path: "/library/movie.en.srt", Language: languageEnglish, Format: "srt", Title: "English"},
},
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: languageEnglish, Codec: "ass", Title: "English (Signs)", Forced: true},
{Index: 1, Language: "jpn", Codec: "pgs", Title: "Japanese"},
{Index: 2, Language: "fre", Codec: "dvd_subtitle", Title: "French"},
},
}
}
func goldenSubtitleAdditional() []playback.SubtitleInventoryEntryV3 {
return []playback.SubtitleInventoryEntryV3{
{Codec: "srt", Language: "spa", Label: "Spanish (downloaded)", Source: playback.SubtitleSourceDownloadedV3},
}
}
// subtitleInventoryFixture is a self-describing conformance vector: the inputs
// a client would receive for the same file, and the ordinals the server
// assigns to them. A client reproduces `inventory` from `source` and compares.
type subtitleInventoryFixture struct {
Description string `json:"description"`
SessionID string `json:"session_id"`
MediaFileID int `json:"media_file_id"`
Source subtitleInventorySource `json:"source"`
Inventory []playback.SubtitleInventoryItemV3 `json:"inventory"`
}
type subtitleInventorySource struct {
ExternalSubtitles []models.ExternalSubtitle `json:"external_subtitles"`
SubtitleTracks []models.SubtitleTrack `json:"subtitle_tracks"`
Downloaded []playback.SubtitleInventoryEntryV3 `json:"downloaded"`
}
func goldenSubtitleInventory() subtitleInventoryFixture {
file := goldenMediaFile()
additional := goldenSubtitleAdditional()
return subtitleInventoryFixture{
Description: "Combined subtitle ordinals are dense and gap-free across externals, embedded tracks, " +
"then downloaded tracks. A track with no sidecar representation keeps its ordinal and is " +
"published as burn_in_only without a URL rather than omitted.",
SessionID: goldenSessionID,
MediaFileID: goldenMediaFileID,
Source: subtitleInventorySource{
ExternalSubtitles: file.ExternalSubtitles,
SubtitleTracks: file.SubtitleTracks,
Downloaded: additional,
},
Inventory: playback.SubtitleInventoryV3(goldenSessionID, file, additional),
}
}
func goldenDecisionResponse() playback.DecisionResponseV3 {
file := conformanceFallbackFile()
subtitleSource := goldenMediaFile()
file.ExternalSubtitles = subtitleSource.ExternalSubtitles
file.SubtitleTracks = subtitleSource.SubtitleTracks
now, err := time.Parse(time.RFC3339, "2029-12-31T23:55:00Z")
if err != nil {
fail("parse golden planner time: %v", err)
}
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: goldenStartRequest(),
RequestedFile: file,
EffectiveFile: file,
AudioTrackIndex: 0,
Settings: playback.PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
Registry: conformanceRegistry(),
Now: now,
AdditionalSubtitles: goldenSubtitleAdditional(),
})
if result.Plan == nil {
fail("golden planner returned terminal: %#v", result.Terminal)
}
plan := *result.Plan
// These values belong to session/transport setup, which runs after the
// planner in the request handler. Keep the planner authoritative for every
// decision field and bind only the deterministic fixture runtime values.
plan.SessionID = goldenSessionID
plan.ExpiresAt = goldenExpiresAt
plan.Stream.URL = "/stream/" + goldenSessionID
plan.Subtitle.Inventory = playback.ScopeSubtitleInventoryV3(goldenSessionID, file, plan.Subtitle.Inventory)
return playback.DecisionResponseV3{
ProtocolVersion: playback.ProtocolV3,
ServerFeatures: playback.ServerFeaturesV3(),
Outcome: playback.OutcomePlayableV3,
SessionID: goldenSessionID,
PlaybackPlan: &plan,
}
}
func goldenCapabilityResponse() playback.CapabilityResponseV3 {
return playback.CapabilityResponseV3{
Enabled: true,
ProtocolVersions: []int{playback.ProtocolV3},
Features: playback.ServerFeaturesV3(),
Deliveries: []playback.DeliveryV3{
playback.DeliveryOriginalHTTPV3,
playback.DeliveryRemuxProgressiveV3,
playback.DeliveryRemuxHLSV3,
playback.DeliveryTranscodeHLSV3,
},
// A real server advertises only what its installed FFmpeg probed; the
// fixture pins the full set so a client sees every shape it must parse.
Transformations: []playback.TransformationV3{
{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{playback.ClaimAudioDecodeV3}},
{Name: playback.TransformationServerDV7HDR10V3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: playback.DV7ToHDR10ClaimsV3()},
{Name: playback.TransformationVideoToH264V3, Executor: playback.ExecutorServerV3, RecipeVersion: playback.TransformationVideoToH264RecipeVersionV3, ValidatedClaims: []string{playback.ClaimH264DecodeV3}},
},
}
}
func goldenRouteEvent() playback.RouteEventV3 {
decision := goldenDecisionResponse()
if decision.PlaybackPlan == nil {
fail("golden decision has no plan")
}
plan := decision.PlaybackPlan
return playback.RouteEventV3{
ProtocolVersion: playback.ProtocolV3,
PlaybackAttemptID: goldenAttemptID,
SessionID: goldenSessionID,
PlanID: plan.PlanID,
PlanAttemptID: "plan-attempt-golden-0001",
PlanAttemptKey: plan.PlanAttemptKey,
Event: playback.RouteEventFirstFrameV3,
OutputContextID: "7",
Diagnostics: map[string]string{
"decoder_name": "c2.android.avc.decoder",
"first_frame_ms": "412",
"video_mime": "video/avc",
},
}
}
// attemptKeyInput exists only inside the Go fixture generator. The inputs to
// the server's identity function are deliberately absent from the generated
// JSON: clients receive a token and prove only that they echo it unchanged.
type attemptKeyInput struct {
Name string `json:"name"`
PlanID string `json:"plan_id"`
Delivery playback.DeliveryV3 `json:"delivery"`
StreamProtocol playback.StreamProtocolV3 `json:"stream_protocol"`
Container string `json:"container"`
VideoCodec string `json:"video_codec"`
AudioCodec string `json:"audio_codec"`
Width int `json:"width"`
Height int `json:"height"`
BitrateKbps int `json:"bitrate_kbps"`
DynamicRange string `json:"dynamic_range"`
SubtitleMode playback.SubtitleModeV3 `json:"subtitle_mode"`
Transformations []playback.TransformationV3 `json:"transformations"`
AppliedQuirks []playback.AppliedQuirkV3 `json:"applied_quirks,omitempty"`
RuntimeCorrections []string `json:"runtime_corrections,omitempty"`
OutputContextID string `json:"output_context_id"`
LocalMutations []string `json:"local_mutations"`
}
func (f attemptKeyInput) plan() playback.PlanV3 {
width, height, bitrate := f.Width, f.Height, f.BitrateKbps
return playback.PlanV3{
PlanID: f.PlanID,
Delivery: f.Delivery,
Stream: playback.StreamV3{Protocol: f.StreamProtocol, Container: f.Container},
EffectiveRecipe: playback.EffectiveRecipeV3{
VideoCodec: f.VideoCodec,
AudioCodec: f.AudioCodec,
Width: &width,
Height: &height,
BitrateKbps: &bitrate,
DynamicRange: f.DynamicRange,
},
Subtitle: playback.SubtitleDecisionV3{Mode: f.SubtitleMode},
Transformations: f.Transformations,
AppliedQuirks: f.AppliedQuirks,
RuntimeCorrections: f.RuntimeCorrections,
}
}
type opaqueAttemptKeyFixture struct {
Name string `json:"name"`
ServerPlanAttemptKey string `json:"server_plan_attempt_key"`
ReplanEcho string `json:"replan_echo"`
AttemptedPlanKeys []string `json:"attempted_plan_keys"`
ExpectedServerAction string `json:"expected_server_action"`
}
func goldenAttemptKeys() []opaqueAttemptKeyFixture {
inputs := []attemptKeyInput{
{
Name: "hls_burn_in_sorted_transformations_and_pcm_mutations",
PlanID: "plan:fixture",
Delivery: playback.DeliveryRemuxHLSV3,
StreamProtocol: playback.StreamHLSV3,
Container: containerHLS,
VideoCodec: codecHEVC,
AudioCodec: codecAAC,
Width: 3840,
Height: 2160,
BitrateKbps: 20_000,
DynamicRange: playback.DynamicRangeHDR10V3,
SubtitleMode: playback.SubtitleBurnInV3,
// Deliberately out of order, and deliberately naming a
// transformation the registry no longer defines: the key preimage
// sorts its inputs and never consults the registry, and pinning a
// retired name proves both properties stay true.
Transformations: []playback.TransformationV3{
{Name: "hdr_to_sdr_tonemap", Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{}},
{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{}},
},
OutputContextID: "7",
LocalMutations: []string{"transport_reopen", "pcm:truehd:8"},
},
{
Name: "direct_client_dv81_executor_and_version",
PlanID: "plan:dv81-fixture",
Delivery: playback.DeliveryOriginalHTTPV3,
StreamProtocol: playback.StreamHTTPProgressiveV3,
Container: containerMKV,
VideoCodec: codecHEVC,
AudioCodec: codecTrueHD,
Width: 3840,
Height: 2160,
BitrateKbps: 65_000,
DynamicRange: playback.DynamicRangeDolbyVisionV3,
SubtitleMode: playback.SubtitleOffV3,
Transformations: []playback.TransformationV3{
{Name: playback.ClientDV7ToDV81V3, Executor: playback.ExecutorClientV3, RecipeVersion: playback.ClientDVTransformVersionV3, ValidatedClaims: []string{}},
},
OutputContextID: "9",
LocalMutations: []string{},
},
{
Name: "direct_device_quirk_and_runtime_correction_identity",
PlanID: "plan:quirk",
Delivery: playback.DeliveryOriginalHTTPV3,
StreamProtocol: playback.StreamHTTPProgressiveV3,
Container: containerMKV,
VideoCodec: codecHEVC,
AudioCodec: "eac3",
Width: 3840,
Height: 2160,
BitrateKbps: 60_000,
DynamicRange: playback.DynamicRangeDolbyVisionV3,
SubtitleMode: playback.SubtitleOffV3,
Transformations: []playback.TransformationV3{},
// The private server implementation includes quirk and runtime
// correction identity; clients see only its opaque result.
AppliedQuirks: []playback.AppliedQuirkV3{{
ID: playback.QuirkFireTVDV8HDR10PlusV3,
RegistryRevision: playback.DeviceQuirkRegistryRevisionV3,
Action: "client_runtime_correction",
}},
RuntimeCorrections: []string{playback.ClientDV8HDR10PlusSanitizerV3},
OutputContextID: "9",
LocalMutations: []string{},
},
}
fixtures := make([]opaqueAttemptKeyFixture, 0, len(inputs))
for _, input := range inputs {
key := playback.PlanAttemptKeyV3(input.plan(), input.OutputContextID, input.LocalMutations)
fixtures = append(fixtures, opaqueAttemptKeyFixture{
Name: input.Name,
ServerPlanAttemptKey: key,
ReplanEcho: key,
AttemptedPlanKeys: []string{key},
ExpectedServerAction: "reject_already_attempted_plan",
})
}
return fixtures
}
func goldenConformanceMatrix() playback.ConformanceMatrixV3 {
videoFile := conformanceVideoFile()
base := conformanceStartRequest()
registry := conformanceRegistry()
settings := playback.PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}
planner := make([]playback.PlannerScenarioV3, 0, 18)
for _, tier := range []playback.CapabilityEvidenceV3{
playback.EvidenceExactV3,
playback.EvidencePlatformAttestedV3,
playback.EvidenceDeclaredV3,
} {
request := base
request.PlaybackAttemptID = "attempt-evidence-" + string(tier)
request.Capabilities.VideoEvidence = tier
if tier == playback.EvidenceDeclaredV3 {
request.Capabilities.VideoDecode = nil
}
planner = append(planner, makePlannerScenario(
"evidence_"+string(tier), "evidence_tier_gating", request, videoFile, nil, settings, registry,
))
}
fallbackRequest := conformanceStartRequest()
fallbackRequest.Capabilities.VideoEvidence = playback.EvidenceDeclaredV3
fallbackRequest.Capabilities.VideoDecode = nil
fallbackRequest.Capabilities.CodecsVideo = []string{codecH264}
fallbackRequest.Capabilities.CodecsVideoHardware = []string{codecH264}
fallbackRequest.Capabilities.Containers = []string{containerMP4}
fallbackFile := conformanceFallbackFile()
var attempted []string
for _, name := range []string{qualityOriginal, "progressive", containerHLS} {
request := fallbackRequest
request.PlaybackAttemptID = "attempt-delivery-chain"
scenario := makePlannerScenario("delivery_"+name, "deliveries_negotiation", request, fallbackFile, attempted, settings, registry)
planner = append(planner, scenario)
attempted = append(attempted, scenario.Expected.PlanAttemptKey)
}
transcodeRequest := fallbackRequest
transcodeRequest.PlaybackAttemptID = "attempt-delivery-transcode"
transcodeRequest.QualityPreference = resolutionHD
planner = append(planner, makePlannerScenario("delivery_transcode", "deliveries_negotiation", transcodeRequest, fallbackFile, nil, settings, registry))
audioFile := &models.MediaFile{ID: 77, BaseType: "audiobook", FilePath: "/media/audiobook.m4b", Container: containerMP4, CodecAudio: codecAAC, Bitrate: 128, AudioChannels: 2, Duration: 39_600, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
audioRequest := conformanceStartRequest()
audioRequest.FileID = audioFile.ID
audioRequest.PlaybackAttemptID = "attempt-audio-only"
audioRequest.Capabilities.Containers = []string{containerMP4}
planner = append(planner, makePlannerScenario("audio_only_original", "audio_only_planning", audioRequest, audioFile, nil, settings, registry))
hdr10Request := conformanceHDRRequest()
hdr10Request.PlaybackAttemptID = "attempt-hdr10-direct"
planner = append(planner, makePlannerScenario("hdr10_exact_direct", "hdr_dv_matrix", hdr10Request, conformanceHDRFile(), nil, settings, registry))
dv8File := conformanceHDRFile()
dv8File.VideoTracks[0].DVProfile = 8
dv8File.VideoTracks[0].DVBLCompatID = 1
dv8File.VideoTracks[0].VideoRange = "DolbyVision"
dv8File.VideoTracks[0].VideoRangeType = "DOVIWithHDR10"
dv8Request := conformanceHDRRequest()
dv8Request.PlaybackAttemptID = "attempt-dv8-direct"
dv8Request.Capabilities.HDRDetails.DolbyVisionProfiles = []int{8}
dv8Request.ClientPlaybackContext.Output.HDRDetails.DolbyVisionProfiles = []int{8}
planner = append(planner, makePlannerScenario("dolby_vision_8_exact_direct", "hdr_dv_matrix", dv8Request, dv8File, nil, settings, registry))
dv7File := conformanceHDRFile()
dv7File.VideoTracks[0].DVProfile = 7
dv7File.VideoTracks[0].DVBLCompatID = 6
dv7File.VideoTracks[0].VideoRange = "DolbyVision"
dv7File.VideoTracks[0].VideoRangeType = "DOVIWithEL"
dv7Request := conformanceHDRRequest()
dv7Request.PlaybackAttemptID = "attempt-dv7-hdr10"
dv7Registry := playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{{Name: playback.TransformationServerDV7HDR10V3, RecipeVersion: "1", Available: true}})
planner = append(planner, makePlannerScenario("dolby_vision_7_hdr10_fallback", "hdr_dv_matrix", dv7Request, dv7File, nil, settings, dv7Registry))
audioAdaptFile := conformanceHDRFile()
audioAdaptFile.CodecAudio = codecTrueHD
audioAdaptFile.AudioChannels = 8
audioAdaptFile.AudioTracks[0] = models.AudioTrack{Codec: codecTrueHD, Channels: 8, Layout: audioLayout71}
audioAdaptRequest := conformanceHDRRequest()
audioAdaptRequest.PlaybackAttemptID = "attempt-truehd-aac"
planner = append(planner, makePlannerScenario("truehd_audio_conversion", "audio_matrix", audioAdaptRequest, audioAdaptFile, nil, settings, registry))
audioPassthroughFile := conformanceHDRFile()
audioPassthroughFile.CodecAudio = codecTrueHD
audioPassthroughFile.AudioChannels = 8
audioPassthroughFile.AudioTracks[0] = models.AudioTrack{Codec: codecTrueHD, Channels: 8, Layout: audioLayout71}
audioPassthroughRequest := conformanceHDRRequest()
audioPassthroughRequest.PlaybackAttemptID = "attempt-truehd-passthrough"
audioPassthroughRequest.ClientFeatures = append(audioPassthroughRequest.ClientFeatures, playback.FeatureLayoutPassthrough)
audioPassthroughRequest.Capabilities.CodecsAudio = []string{codecTrueHD}
audioPassthroughRequest.Capabilities.AudioPassthrough = &playback.AudioPassthroughV3{
PassthroughCodecs: []string{codecTrueHD}, MaxChannels: 8,
Entries: []playback.AudioPassthroughEntryV3{{Codec: codecTrueHD, ChannelCounts: []int{8}, Layouts: []string{audioLayout71}}},
}
originalDelivery := audioPassthroughRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3]
originalDelivery.AudioPassthroughCodecs = []string{codecTrueHD}
audioPassthroughRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = originalDelivery
planner = append(planner, makePlannerScenario("truehd_exact_layout_passthrough", "audio_matrix", audioPassthroughRequest, audioPassthroughFile, nil, settings, registry))
pgsFile := conformanceHDRFile()
pgsFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: "jpn", Codec: "hdmv_pgs_subtitle", Title: "Japanese"}}
pgsRequest := conformanceHDRRequest()
pgsRequest.PlaybackAttemptID = "attempt-pgs-sidecar"
pgsIndex := 0
pgsRequest.SubtitleTrackIndex = &pgsIndex
pgsRequest.SubtitleTrackID = playback.TrackIDV3(pgsFile.ID, "subtitle", pgsIndex)
for _, deliveryClass := range []string{playback.DeliveryClassOriginalHTTPV3, playback.DeliveryClassProgressiveV3, playback.DeliveryClassHLSV3} {
delivery := pgsRequest.ClientPlaybackContext.Deliveries[deliveryClass]
delivery.Subtitles.EmbeddedBitmap = true
pgsRequest.ClientPlaybackContext.Deliveries[deliveryClass] = delivery
}
planner = append(planner, makePlannerScenario("embedded_pgs_sidecar", "subtitle_matrix", pgsRequest, pgsFile, nil, settings, registry))
assFile := conformanceHDRFile()
assFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: languageEnglish, Codec: "ass", Title: "English Signs"}}
assRequest := conformanceHDRRequest()
assRequest.PlaybackAttemptID = "attempt-ass-authored"
assRequest.SubtitleFidelityPreference = playback.SubtitleFidelityPreserveV3
assIndex := 0
assRequest.SubtitleTrackIndex = &assIndex
assRequest.SubtitleTrackID = playback.TrackIDV3(assFile.ID, "subtitle", assIndex)
for _, deliveryClass := range []string{playback.DeliveryClassOriginalHTTPV3, playback.DeliveryClassProgressiveV3, playback.DeliveryClassHLSV3} {
delivery := assRequest.ClientPlaybackContext.Deliveries[deliveryClass]
delivery.Subtitles.EmbeddedText = true
delivery.Subtitles.ASSStyling = true
delivery.Subtitles.FontAttachments = true
assRequest.ClientPlaybackContext.Deliveries[deliveryClass] = delivery
}
planner = append(planner, makePlannerScenario("embedded_ass_authored_render", "subtitle_matrix", assRequest, assFile, nil, settings, registry))
dvdFile := conformanceVideoFile()
dvdFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: languageEnglish, Codec: "dvd_subtitle", Title: "English"}}
dvdRequest := conformanceStartRequest()
dvdRequest.PlaybackAttemptID = "attempt-dvd-burn-in"
dvdIndex := 0
dvdRequest.SubtitleTrackIndex = &dvdIndex
dvdRequest.SubtitleTrackID = playback.TrackIDV3(dvdFile.ID, "subtitle", dvdIndex)
planner = append(planner, makePlannerScenario("embedded_dvd_burn_in", "subtitle_matrix", dvdRequest, dvdFile, nil, settings, registry))
qualityRequest := fallbackRequest
qualityRequest.PlaybackAttemptID = "attempt-available-qualities"
qualityRequest.QualityPreference = qualityOriginal
planner = append(planner, makePlannerScenario("available_qualities", "available_qualities", qualityRequest, fallbackFile, nil, settings, registry))
decision := goldenDecisionResponse()
plan := decision.PlaybackPlan
if plan == nil {
fail("golden decision has no plan")
}
trackIndex := 1
trackChange := goldenReplanRequest()
trackChange.Operation = playback.ReplanOperationTrackChangeV3
trackChange.ReplanRequestID = "replan-track-change-0001"
trackChange.Failure = playback.FailureV3{}
trackChange.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: "", Index: &trackIndex}
qualityChange := goldenReplanRequest()
qualityChange.Operation = playback.ReplanOperationQualityChangeV3
qualityChange.ReplanRequestID = "replan-quality-change-0001"
qualityChange.Failure = playback.FailureV3{}
qualityChange.QualityPreference = resolutionHD
outputChange := goldenReplanRequest()
outputChange.Operation = playback.ReplanOperationOutputChangeV3
outputChange.ReplanRequestID = "replan-output-change-0001"
outputChange.Failure = playback.FailureV3{}
seekReanchor := goldenReplanRequest()
seekReanchor.Operation = playback.ReplanOperationSeekReanchorV3
seekReanchor.ReplanRequestID = "replan-seek-reanchor-0001"
seekReanchor.Failure = playback.FailureV3{}
seekReanchor.PositionSeconds = 321.25
trackChange.PositionSeconds = 321.25
qualityChange.PositionSeconds = 321.25
trackDuplicate := trackChange
trackDuplicate.ReplanRequestID = "replan-track-duplicate-0001"
qualityDuplicate := qualityChange
qualityDuplicate.ReplanRequestID = "replan-quality-duplicate-0001"
trackConcurrent := trackChange
trackConcurrent.ReplanRequestID = "replan-track-concurrent-0001"
qualityConcurrent := qualityChange
qualityConcurrent.ReplanRequestID = "replan-quality-concurrent-0001"
trackMidSeek := trackChange
trackMidSeek.ReplanRequestID = "replan-track-mid-seek-0001"
qualityMidSeek := qualityChange
qualityMidSeek.ReplanRequestID = "replan-quality-mid-seek-0001"
replans := []playback.ReplanScenarioV3{
{Name: "track_change", Category: "track_change_replan", Request: trackChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PreserveUnmodifiedTracks: true}},
{Name: "quality_change", Category: "quality_change_replan", Request: qualityChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, SelectedQuality: resolutionHD}},
{Name: "output_change", Category: "output_change_replan", Request: outputChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PreserveUnmodifiedTracks: true}},
{Name: "track_change_idempotent_duplicate", Category: "idempotent_replan", Request: trackDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
{Name: "quality_change_idempotent_duplicate", Category: "idempotent_replan", Request: qualityDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
{Name: "track_change_concurrent_duplicate", Category: "concurrent_replan", Request: trackConcurrent, Expected: playback.ReplanExpectationV3{WhileFirstLeaseActiveStatus: http.StatusConflict, ConcurrentError: "replan_in_progress", AfterCompletionStatus: http.StatusOK, ResponseReplayedVerbatim: true}},
{Name: "quality_change_concurrent_duplicate", Category: "concurrent_replan", Request: qualityConcurrent, Expected: playback.ReplanExpectationV3{WhileFirstLeaseActiveStatus: http.StatusConflict, ConcurrentError: "replan_in_progress", AfterCompletionStatus: http.StatusOK, ResponseReplayedVerbatim: true}},
{Name: "track_change_mid_seek", Category: categoryMidSeek, Request: trackMidSeek, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: trackMidSeek.PositionSeconds, PositionPreserved: true}},
{Name: "quality_change_mid_seek", Category: categoryMidSeek, Request: qualityMidSeek, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: qualityMidSeek.PositionSeconds, PositionPreserved: true}},
{Name: "mid_seek_reanchor", Category: categoryMidSeek, Request: seekReanchor, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: seekReanchor.PositionSeconds, PositionPreserved: true}},
}
outputA := playback.PlanAttemptKeyV3(*plan, "output-a", nil)
outputB := playback.PlanAttemptKeyV3(*plan, "output-b", nil)
if outputA == outputB {
fail("output context must change the opaque plan attempt key")
}
recovery := goldenReplanRequest()
recovery.ReplanRequestID = "replan-failure-matrix-0001"
recovery.PositionSeconds = 321.25
subtitleIndex := 2
recovery.SelectedTracks.Subtitle = &playback.TrackIdentityV3{
ID: playback.TrackIDV3(goldenMediaFileID, "subtitle", subtitleIndex),
Index: &subtitleIndex,
}
restartStart := goldenStartRequest()
restartStart.PlaybackAttemptID = "attempt-restart-terminal"
restartTerminal := playback.NewTerminalResponseV3(
"transcode_start_failed",
"The playback transport did not become ready in time.",
true,
)
capacityStart := goldenStartRequest()
capacityStart.PlaybackAttemptID = "attempt-capacity-unavailable"
capacityAvailable := false
zeroCapacityDelta := 0
limitEvent := goldenRouteEvent()
limitEvent.PlaybackAttemptID = "attempt-route-limit"
limitEvent.Diagnostics = make(map[string]string, 33)
for index := range 33 {
limitEvent.Diagnostics[fmt.Sprintf("diagnostic_%02d", index)] = "value"
}
draftProtocolVersion := playback.ProtocolV3
protocol := []playback.ProtocolScenarioV3{
{Name: "legacy_start_requires_upgrade", Category: "legacy_426", Input: playback.ProtocolScenarioInputV3{LegacyStartBody: &playback.LegacyStartBodyV3{FileID: goldenMediaFileID}}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusUpgradeRequired, Error: "client_upgrade_required"}},
{Name: "draft_v3_start_requires_upgrade", Category: "draft_v3_426", Input: playback.ProtocolScenarioInputV3{LegacyStartBody: &playback.LegacyStartBodyV3{ProtocolVersion: &draftProtocolVersion, FileID: goldenMediaFileID, ClientCapabilities: &playback.DraftClientCapabilitiesV3{CodecsVideo: []string{codecH264}}}}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusUpgradeRequired, Error: "client_upgrade_required"}},
{Name: "output_context_change_invalidates_attempt", Category: "output_context_invalidation", Input: playback.ProtocolScenarioInputV3{PlanID: plan.PlanID, FirstOutputContextID: "output-a", SecondOutputContextID: "output-b", FirstPlanAttemptKey: outputA, SecondPlanAttemptKey: outputB}, Expected: playback.ProtocolExpectationV3{PlanIDUnchanged: true, PlanAttemptKeyChanged: true}},
{Name: "opaque_attempt_key_loop", Category: "attempt_key_echo_and_loop", Input: playback.ProtocolScenarioInputV3{ServerPlanAttemptKey: plan.PlanAttemptKey, ReplanEcho: plan.PlanAttemptKey, AttemptedPlanKeys: []string{plan.PlanAttemptKey}}, Expected: playback.ProtocolExpectationV3{Action: "reject_already_attempted_plan"}},
{Name: "failure_recovery_preserves_intent", Category: "recovery_matrix", Input: playback.ProtocolScenarioInputV3{ReplanRequest: &recovery}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusOK, SelectionPreserved: true, PositionPreserved: true, Action: "preserve_selected_tracks_and_position"}},
{Name: "restart_replays_terminal_attempt", Category: "restart_matrix", Input: playback.ProtocolScenarioInputV3{StartRequest: &restartStart, PersistedDecision: &restartTerminal, Restarted: true}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusCreated, Outcome: playback.OutcomeAdaptationUnavailableV3, TerminalReason: "transcode_start_failed", ResponseReplayedVerbatim: true, CapacityDelta: &zeroCapacityDelta}},
{Name: "capacity_unavailable_cleans_up", Category: "capacity_matrix", Input: playback.ProtocolScenarioInputV3{StartRequest: &capacityStart, CapacityAvailable: &capacityAvailable}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusCreated, Outcome: playback.OutcomeAdaptationUnavailableV3, TerminalReason: "capacity_unavailable", CapacityDelta: &zeroCapacityDelta, CleanupComplete: true}},
{Name: "route_event_diagnostic_limit", Category: "route_event_limits", Input: playback.ProtocolScenarioInputV3{RouteEvent: &limitEvent}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusBadRequest, Error: "bad_request", Action: "reject_without_persisting"}},
}
return playback.ConformanceMatrixV3{SchemaVersion: 1, Planner: planner, Replans: replans, Protocol: protocol}
}
func makePlannerScenario(name, category string, request playback.StartRequestV3, file *models.MediaFile, attempted []string, settings playback.PlannerSettingsV3, registry *playback.TransformationRegistryV3) playback.PlannerScenarioV3 {
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: settings, Registry: registry, AttemptedKeys: attempted,
})
expected := playback.PlannerExpectationV3{Outcome: playback.OutcomeAdaptationUnavailableV3}
if result.Plan != nil {
selectedTracks := result.Plan.SelectedTracks
subtitle := result.Plan.Subtitle
claims := result.Plan.Claims
expected = playback.PlannerExpectationV3{
Outcome: playback.OutcomePlayableV3,
Delivery: result.Plan.Delivery,
DecisionReason: result.Plan.DecisionReason,
PlanID: result.Plan.PlanID,
PlanAttemptKey: result.Plan.PlanAttemptKey,
SelectedTracks: &selectedTracks,
Subtitle: &subtitle,
Claims: &claims,
Transformations: append([]playback.TransformationV3(nil), result.Plan.Transformations...),
AvailableQualities: result.Plan.AvailableQualities,
}
} else if result.Terminal != nil {
expected.TerminalReason = result.Terminal.Reason
}
return playback.PlannerScenarioV3{
Name: name, Category: category, Request: request,
Source: playback.SourceDescriptorFromFileV3(file, 0),
AttemptedKeys: append([]string(nil), attempted...), Expected: expected,
}
}
func conformanceStartRequest() playback.StartRequestV3 {
request := goldenStartRequest()
request.QualityPreference = qualityOriginal
request.Capabilities = playback.ClientCodecCapabilitiesV3{
VideoEvidence: playback.EvidenceExactV3, AudioEvidence: playback.EvidenceExactV3,
CodecsVideo: []string{codecHEVC}, CodecsVideoHardware: []string{codecHEVC}, CodecsAudio: []string{codecAAC}, Containers: []string{containerMKV}, MaxResolution: resolutionFHD,
VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: codecHEVC, Profiles: []string{"main 10"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}},
}
request.ClientPlaybackContext = playback.ClientPlaybackContextV3{
ProtocolVersion: playback.ProtocolV3, FormFactor: "tv", AppVersion: "3.0-test",
Device: playback.DeviceContextV3{Platform: "fixture"}, Output: playback.OutputContextV3{OutputContextID: "output-a"},
Deliveries: map[string]playback.DeliveryCapabilityV3{
playback.DeliveryClassOriginalHTTPV3: conformanceDelivery([]string{containerMKV, containerMP4}),
playback.DeliveryClassProgressiveV3: conformanceDelivery([]string{containerMP4}),
playback.DeliveryClassHLSV3: conformanceDelivery([]string{containerHLS}),
},
}
return request
}
func conformanceDelivery(containers []string) playback.DeliveryCapabilityV3 {
return playback.DeliveryCapabilityV3{
Enabled: true,
SupportedOnDevice: true,
Containers: append([]string(nil), containers...),
VideoCodecs: []string{codecHEVC, codecH264},
AudioDecodeCodecs: []string{codecAAC},
AudioPassthroughCodecs: []string{},
Features: []string{},
ValidatedClaims: []string{},
Transformations: []playback.TransformationV3{},
}
}
func conformanceVideoFile() *models.MediaFile {
return &models.MediaFile{ID: goldenMediaFileID, Container: containerMKV, CodecVideo: codecHEVC, CodecAudio: codecAAC, Resolution: resolutionFHD, Bitrate: 8_000, AudioChannels: 2, Duration: 7_200, VideoTracks: []models.VideoTrack{{Codec: codecHEVC, Profile: "Main", Level: 41, Width: 1920, Height: 1080, FrameRate: filmFrameRate, Bitrate: 8_000, BitDepth: 8, VideoRange: videoRangeSDR, VideoRangeType: videoRangeSDR}}, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
}
func conformanceHDRFile() *models.MediaFile {
return &models.MediaFile{
ID: goldenMediaFileID,
Container: containerMKV,
CodecVideo: codecHEVC,
CodecAudio: codecAAC,
Resolution: "2160p",
Bitrate: 60_000,
AudioChannels: 2,
Duration: 7_200,
VideoTracks: []models.VideoTrack{{
Codec: codecHEVC, Profile: "Main 10", Level: 153,
Width: 3840, Height: 2160, FrameRate: filmFrameRate, Bitrate: 60_000, BitDepth: 10,
VideoRange: "HDR", VideoRangeType: "HDR10", ColorRange: "tv",
}},
AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}},
}
}
func conformanceHDRRequest() playback.StartRequestV3 {
request := conformanceStartRequest()
request.Capabilities.CodecsVideo = []string{codecHEVC}
request.Capabilities.CodecsVideoHardware = []string{codecHEVC}
request.Capabilities.CodecsAudio = []string{codecAAC}
request.Capabilities.Containers = []string{containerMKV}
request.Capabilities.MaxResolution = "2160p"
request.Capabilities.HDR = true
request.Capabilities.HDRDetails = &playback.HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{}}
request.Capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
Codec: codecHEVC, Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10},
MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true,
}}
request.ClientPlaybackContext.Output.HDRDetails = &playback.HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{}}
return request
}
func conformanceFallbackFile() *models.MediaFile {
return &models.MediaFile{ID: goldenMediaFileID, FilePath: "/media/movie.mp4", Container: containerMP4, CodecVideo: codecH264, CodecAudio: codecAAC, Resolution: resolutionFHD, Bitrate: 8_000, AudioChannels: 2, Duration: 7_200, VideoTracks: []models.VideoTrack{{Codec: codecH264, Profile: profileHigh, Level: 41, Width: 1920, Height: 1080, FrameRate: filmFrameRate, Bitrate: 8_000, BitDepth: 8, VideoRange: videoRangeSDR, VideoRangeType: videoRangeSDR}}, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
}
func conformanceRegistry() *playback.TransformationRegistryV3 {
return playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: playback.TransformationAudioToAACV3, RecipeVersion: "1", Available: true},
{Name: playback.TransformationVideoToH264V3, RecipeVersion: "1", Available: true},
{Name: playback.TransformationServerDV7HDR10V3, RecipeVersion: "1", Available: true},
})
}
+36 -11
View File
@@ -718,9 +718,19 @@ func main() {
var handler http.Handler
if mode == "proxy" {
srv := proxy.NewServer(watcher, tracker)
srv.SetRemoteArtifactMissReporter(downloads.NewArtifactManager(
downloads.NewArtifactRepository(pool),
downloads.NewRepository(pool),
nil,
downloads.NewPlaybackPreparer(),
nodeID,
watcher.Config,
nil,
))
handler = srv.Handler()
} else {
srv := transcodenode.NewServer(watcher, tracker)
srv.SetInputPathAuthorizer(transcodenode.NewCatalogPathAuthorizer(scanner.NewFileRepository(pool)))
srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID))
// Read jellycompat reconstruction recipes central wrote at transcode
// start, so this node can rebuild a Jellyfin transcode after its own
@@ -920,8 +930,14 @@ func main() {
proxyPool := nodepool.NewProxyPool()
transcodePool := nodepool.NewTranscodePool()
proxyNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy)
transcodeNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode)
proxyNodes, err := nodeRepo.ListEnabled(appCtx, nodepool.NodeTypeProxy)
if err != nil {
log.Fatalf("load enabled proxy nodes: %v", err)
}
transcodeNodes, err := nodeRepo.ListEnabled(appCtx, nodepool.NodeTypeTranscode)
if err != nil {
log.Fatalf("load enabled transcode nodes: %v", err)
}
proxyPool.SetNodes(proxyNodes)
transcodePool.SetNodes(transcodeNodes)
@@ -2060,20 +2076,29 @@ func main() {
// Download prepare-to-file pipeline (Phase 3): a durable, leased encode
// queue hosted on the task manager. Built here (before Start) and shared
// with the API via deps so the download service can enqueue jobs.
liveDownloadConfig := func() *config.Config {
if deps.LiveConfig != nil {
if c := deps.LiveConfig(); c != nil {
return c
}
}
return deps.Config
}
var downloadWorkPlanner nodepool.TranscodeWorkPlanner
if deps.NodePlanner != nil {
downloadWorkPlanner = deps.NodePlanner
}
preparer := downloads.NewNodeAwarePreparer(downloads.NewPlaybackPreparer(), downloadWorkPlanner, liveDownloadConfig)
if deps.NodeRepo != nil {
preparer.SetOriginLookup(deps.NodeRepo)
}
artifactMgr := downloads.NewArtifactManager(
downloads.NewArtifactRepository(deps.DB),
downloads.NewRepository(deps.DB),
deps.FileRepo,
downloads.NewPlaybackPreparer(),
preparer,
deps.NodeID,
func() *config.Config {
if deps.LiveConfig != nil {
if c := deps.LiveConfig(); c != nil {
return c
}
}
return deps.Config
},
liveDownloadConfig,
func(ctx context.Context, d *downloads.Download) {
if deps.EventsHub == nil {
return
+2
View File
@@ -25,6 +25,8 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientBuild: s.ClientBuild,
ClientChannel: s.ClientChannel,
ClientUserAgent: s.ClientUserAgent,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
+67 -1
View File
@@ -1,6 +1,6 @@
{
"fixture_version": 1,
"manifest_revision": 5,
"manifest_revision": 7,
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
"cases": [
{
@@ -667,6 +667,72 @@
"source": "profile"
}
]
},
{
"name": "intro_skip_mode_defaults_to_ask",
"description": "A profile that has never chosen an intro behaviour gets the contract default, which is the same prompt the old auto_skip_intro=false produced.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "ask",
"source": "default"
}
]
},
{
"name": "intro_skip_mode_device_override_beats_profile",
"description": "A living-room television set to skip intros automatically does not change the profile-wide choice to leave them alone.",
"keys": ["playback.intro_skip_mode"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.intro_skip_mode",
"scope": "profile",
"profile_id": "p1",
"value": "never"
},
{
"key": "playback.intro_skip_mode",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "always"
}
],
"expected": [
{
"key": "playback.intro_skip_mode",
"value": "always",
"source": "profile_device"
}
]
},
{
"name": "subtitle_appearance_uses_shared_default",
"description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.",
"keys": ["playback.subtitle_appearance"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [],
"expected": [
{
"key": "playback.subtitle_appearance",
"value": {
"fontSize": "large",
"fontFamily": "sans-serif",
"fontColor": "#ffffff",
"backgroundColor": "#000000",
"backgroundStyle": "box",
"backgroundOpacity": 75,
"textOutline": false,
"textOutlineColor": "#000000",
"position": "bottom"
},
"source": "default"
}
]
}
]
}
+27 -4
View File
@@ -1,6 +1,6 @@
{
"api_version": 1,
"revision": 5,
"revision": 7,
"option_sets": {
"playback_audio_languages": {
"type": "language_tag",
@@ -233,7 +233,7 @@
"fontFamily": "sans-serif",
"fontColor": "#ffffff",
"backgroundColor": "#000000",
"backgroundStyle": "shadow",
"backgroundStyle": "box",
"backgroundOpacity": 75,
"textOutline": false,
"textOutlineColor": "#000000",
@@ -243,7 +243,7 @@
"label": "Subtitle appearance",
"description": "How subtitles are drawn during playback.",
"recommended_control": "panel",
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. The default below is the web client's; Apple defaults to a box background and Android to no background with an outline, so migration must first write each platform's own default into a row for users who never opened the panel, or their subtitles silently change appearance at cutover."
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative."
},
{
"key": "playback.preferred_quality",
@@ -305,7 +305,30 @@
"category": "playback",
"label": "Auto-skip intros",
"description": "Jump past intros automatically when Silo can detect them.",
"recommended_control": "switch"
"recommended_control": "switch",
"deprecated": true,
"notes": "Superseded by playback.intro_skip_mode in revision 7: true is \"always\", false is \"ask\". The boolean cannot express \"never\" (no prompt at all), which is the mode this setting was missing. It stays in the manifest because every shipped client reads it and the profile DTO carries it as a NOT NULL column, and the server mirrors the two keys at write time for one release so a preference set on an old client shows up correctly on a new one. Removing it is a follow-up, once Android, Apple and web all read the enum."
},
{
"key": "playback.intro_skip_mode",
"introduced_in": 7,
"persistence": "remote",
"allowed_scopes": ["profile", "profile_device"],
"resolution_order": ["profile_device", "profile", "default"],
"value_schema": {
"type": "enum",
"values": [
{ "value": "never", "label": "Never" },
{ "value": "ask", "label": "Ask to skip" },
{ "value": "always", "label": "Skip automatically" }
]
},
"default_value": "ask",
"category": "playback",
"label": "Skip intros",
"description": "What Silo does when an intro starts: leave it alone, offer a Skip Intro button, or skip it and offer an undo.",
"recommended_control": "select",
"notes": "The replacement for playback.auto_skip_intro, which could only say \"prompt\" or \"count down then skip\" and had no way to turn the prompt off. The default is \"ask\", which is exactly what auto_skip_intro=false did, so an untouched profile behaves identically across the cutover. The schema has no segmented control, so this is a select; clients that have a segmented control should use it. See docs/design/2026-08-16-intro-skip-mode.md."
},
{
"key": "playback.auto_skip_credits",
-2
View File
@@ -123,7 +123,6 @@ services:
- silo-dev-plugins:/var/lib/silo/plugins
- silo-dev-compat:/var/lib/silo/compat
- silo-dev-transcode:/tmp/silo-transcode
- silo-dev-audiobook-covers:/var/lib/silo/audiobook-covers
- ${MEDIA_ROOT:-/tmp/silo-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
depends_on:
postgres:
@@ -169,7 +168,6 @@ services:
volumes:
silo-dev-air-tmp:
silo-dev-audiobook-covers:
silo-dev-compat:
silo-dev-go-build:
silo-dev-go-mod:
+10
View File
@@ -0,0 +1,10 @@
services:
silo:
devices:
- /dev/dri:/dev/dri
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-transcode:
# devices:
# - /dev/dri:/dev/dri
+6 -7
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
POSTGRES_DB: ${POSTGRES_DB:-silo}
ports:
- "5432:5432"
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
- ${SILO_DATA_ROOT:-/opt/silo}/postgres:/var/lib/postgresql
command: ["postgres", "-c", "listen_addresses=*"]
@@ -22,7 +22,7 @@ services:
image: redis:alpine
restart: unless-stopped
ports:
- "6379:6379"
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
volumes:
- ${SILO_DATA_ROOT:-/opt/silo}/redis:/data
healthcheck:
@@ -56,6 +56,10 @@ services:
restart: unless-stopped
environment:
MODE: integrated
# Keep the container listeners fixed. PORT and JF_PORT in .env are
# host-side published-port overrides.
PORT: "8080"
JF_PORT: "8096"
# Master key for at-rest credential encryption. Required — the server
# refuses to start without it. Generate with: openssl rand -base64 48
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
@@ -71,12 +75,10 @@ services:
- "${ABS_PORT:-13378}:13378"
volumes:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/compat:/var/lib/silo/compat
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
- /proc/meminfo:/host/proc/meminfo:ro
depends_on:
postgres:
@@ -86,9 +88,6 @@ services:
env_file:
- path: .env
required: false
devices:
- /dev/dri:/dev/dri
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-proxy:
+14
View File
@@ -31,6 +31,20 @@ entire current entity as `200 OK`, preventing bytes from different revisions
from being combined. `If-None-Match` uses the same validator for ordinary
conditional requests.
For a bounded chunk read, a client can instead send `Range` and `If-Match`.
When the validator matches, the server returns the requested `206` interval.
When it does not match, the server returns `412 Precondition Failed` without a
body. This contract avoids materializing a full entity when a bounded read's
validator is stale. It does not change `If-Range`: an `If-Range` mismatch still
ignores the range and returns the full current entity as `200 OK`, as required
by the HTTP range contract.
Direct-stream completion logs record whether `If-Match` and `If-Range` were
present, a bounded conditional-result value, and short SHA-256 fingerprints for
the emitted and requested validators. The raw validators and media path are not
logged. The fingerprints are only correlation aids and are not protocol
validators.
Playback sessions already treat each transport request independently.
Sequential ranged requests refresh transport activity, and cleanup never
expires a session while one of those transport requests is active. A late
+847
View File
@@ -0,0 +1,847 @@
# Playback protocol v3
The wire contract between a Silo client and a Silo server for deciding *how* a
piece of media will play, and for recovering when that decision turns out to be
wrong on the device in front of the user.
> **Status: normative.** This document, the JSON Schemas under
> [`docs/design/schemas/playback-v3/`](../design/schemas/playback-v3/), and the
> golden fixtures under `internal/playback/testdata/protocol_v3/` are the
> contract. Where this document and a client implementation disagree, the client
> is wrong. Where this document and the server implementation disagree, that is a
> bug in one of them — file it.
This is written to be sufficient on its own. A third-party client should be able
to implement playback against a Silo server from this document plus the schemas,
without reading any client repository and without reading the server source.
Paths are repository-relative; assume the repository root is the cwd.
---
## 1. Design invariants
Five properties hold everywhere in the protocol. Most of the surprising details
below follow from one of them.
**The server owns the decision.** A client reports what it can do; the server
decides what will be sent and how. Clients do not pick a bitrate ladder rung, do
not choose a container, do not decide whether to remux, and do not post a
transcode recipe. The `playback_plan` in a response is the whole instruction.
**Plan identity is deterministic.** `plan_id` is a pure function of the request
identity and the plan's own shape (§9). The same decision made twice produces
the same `plan_id`. This is what makes replans idempotent rather than merely
retried.
**Attempt keys are server-owned and opaque.** `plan_attempt_key` identifies "this
plan, on this output, with these local mutations" for loop prevention. It is a
hash the server computes. A client stores it, echoes it back, and never parses,
compares by substring, or recomputes it. Its algorithm and preimage are server
implementation details; §9 specifies only the stability clients may rely on.
**Claims are validated, not assumed.** When the server says a route preserves
Atmos, or that Dolby Vision metadata was removed, that claim was checked against
evidence the client supplied, at the strictness its evidence tier allows (§3).
The server never claims something it did not verify.
**Route events are diagnostics, not control.** A client reports what happened
(`first_frame`, `plan_failed`, `terminal`) so the server can learn; the report
never changes the session. Playback recovery goes through replan (§6), which is a
request with a response, not a fire-and-forget event.
Two consequences worth stating early, because they surprise implementers:
- `POST /playback/start` returns **201 for every outcome**, including a terminal
refusal to play. A terminal is a decision, not a transport failure. 4xx means
the request was malformed; it never means "this media cannot play."
- `protocol_version: 3` is carried on every body independently, and each is
validated separately: the start request, the `client_playback_context` nested
inside it, the replan request, the route event, the decision response, and the
plan within it. A start request whose envelope says 3 but whose
`client_playback_context` says otherwise is rejected. There is no negotiation
and no fallback — a server that does not speak v3 is not a server this
contract describes.
---
## 2. Endpoints
All paths are relative to `/api/v1`. Every endpoint requires an authenticated
user. The mutation endpoints additionally require a profile, supplied as the
`X-Profile-Id` header.
Every non-2xx response body is the standard error envelope:
```json
{"error": "<machine_code>", "message": "<human sentence>"}
```
The `error` code is the stable part; the `message` is for logs and is not
contract.
### 2.1 `GET /playback/capability`
Feature detection. Auth required; no profile needed.
| Status | Meaning |
| --- | --- |
| `200` | Capability document |
| `401` `unauthorized` | No authenticated user |
v3 is the server's only playback protocol, so `enabled` is constant `true` and
the document is always the full one:
```json
{
"enabled": true,
"protocol_versions": [3],
"features": ["playback_plan_v3", "neutral_playback_v3_contract_v1", "layout_aware_passthrough", "playback_route_diagnostics",
"device_quirks_v1", "seek_reanchor_v1", "output_change_v1", "direct_stream_resume_v1",
"plan_source_duration_v1"],
"deliveries": ["original_http", "server_remux_progressive", "server_remux_hls", "server_transcode_hls"],
"transformations": [{"name": "audio_to_aac", "executor": "server", "recipe_version": "1", "validated_claims": ["audio_decode"]}]
}
```
The nine feature strings above are the full set this server version advertises:
| Feature | What it promises |
| --- | --- |
| `playback_plan_v3` | The three plan endpoints exist and behave as specified here |
| `neutral_playback_v3_contract_v1` | The server mints opaque `plan_attempt_key` values that clients only echo, and exposes track/quality intent replans distinct from failure recovery |
| `layout_aware_passthrough` | Audio passthrough is decided from channel *layouts*, not just channel counts (§3) |
| `playback_route_diagnostics` | `POST /playback/route-events` is accepted |
| `device_quirks_v1` | Plans may carry `applied_quirks` and `runtime_corrections` (§9) |
| `seek_reanchor_v1` | The `seek_reanchor` replan operation is available (§6) |
| `output_change_v1` | The `output_change` intent replan is available; clients must keep the active route when this feature is absent |
| `direct_stream_resume_v1` | A direct route may resume mid-file rather than restarting |
| `plan_source_duration_v1` | `source.duration_seconds` is populated when known, so its absence means *unknown* rather than *unsupported* (§5) |
That last one is the reason feature detection is a list and not a version
number: without it, a client cannot tell a server that never sends the runtime
apart from a server that knows this particular file's runtime is genuinely
unknown, and both look like an absent field.
`deliveries` reports the four *server-side* delivery values, not the three
delivery classes a client negotiates in. §4 gives the folding.
`transformations` advertises only what the *installed* FFmpeg was probed for at
startup — a server without a `dovi_rpu` bitstream filter does not list
`server_dv7_to_hdr10`. A client must not assume a transformation exists because
this document names it.
`enabled` survives from the rollout period and is now constant `true`; the
negative shape was deliberately removed before v1 lock because v3 is the only
playback protocol. `reason` remains an optional diagnostic for a future
non-rollout condition, but it never changes the meaning of `enabled`.
### 2.2 `POST /playback/start`
Requests a plan. Auth + `X-Profile-Id` required. Request body cap: **256 KiB**.
Body: `StartRequestV3`
([`start-request.schema.json`](../design/schemas/playback-v3/v3/start-request.schema.json)).
| Status | Code | Meaning |
| --- | --- | --- |
| `201` | — | A decision was made. Body is `DecisionResponseV3`: either `outcome: "playable"` with a `playback_plan`, or `outcome: "adaptation_unavailable"` with a `terminal`. |
| `400` | `bad_request` | Malformed JSON, failed validation (the `message` is the validator's own text), missing `X-Profile-Id`, or `profile_id` disagreeing with the header |
| `401` | `unauthorized` | No authenticated user |
| `404` | `not_found` | `file_id` does not exist, is marked missing, or this profile cannot see it |
| `409` | `playback_attempt_reused` | This `playback_attempt_id` was already used for a *different* request |
| `426` | `client_upgrade_required` | The body does not declare the finalized v3 shape: `protocol_version: 3` plus both capability evidence markers |
| `500` | `internal_error` | Session store failure |
A file the profile is not allowed to see is `404`, not `403` — parental and
library restrictions do not confirm that a hidden item exists.
The `426` is what a pre-v3 or draft-v3 client gets. There is no protocol negotiation and no
fallback: the server decodes v3 or it refuses, and the client is expected to
render an "update required" state rather than retry. It is deliberately not a
`400` — the request may be perfectly well-formed for the protocol it was written
against, and the distinction is what lets a client tell "I sent something wrong"
apart from "I am too old to talk to this server".
Note the layering: a request whose *body* is fine but whose *media* cannot be
played is not an HTTP error. It is a `201` with `outcome:
"adaptation_unavailable"` and a terminal reason from §7.3. HTTP statuses on this
endpoint describe the request; the decision lives in the body.
**Idempotency.** The server stores each `playback_attempt_id` alongside a
SHA-256 digest of the exact request body. Replaying a byte-identical body
replays the original response verbatim. Reusing the id with a different body is
`409 playback_attempt_reused` — the id is a claim about *which* playback attempt
this is, so reusing it for different intent is a client bug the server refuses to
paper over. If the attempt is known but its session has since expired, the
response is a `201` terminal with reason `session_expired` rather than a replay
of a plan that no longer exists.
Playable and terminal decisions are both durable attempts. A terminal start has
no `session_id` or plan identity, but its response and ownership are retained
under `playback_attempt_id` for the same TTL. This makes terminal retries obey
the same replay/conflict rules and gives terminal route events an addressable
authorization record.
A client generates a fresh `playback_attempt_id` per user-initiated playback and
reuses it only to retry a request whose response it did not receive.
**Omission is a request, not a default.** Two start fields mean "you decide" when
absent, and the server answers from stored user state rather than from a
constant:
| Field | Omitted | Present |
| --- | --- | --- |
| `start_position` | The profile's saved resume point for this item, or `0` when there is none, it is already complete, or the file is one part of a multipart item (every part shares the item's resume point, so a part-local seek to it would land somewhere arbitrary). It is required when `progress_persistence` is `client` | Exactly that position. `0` means *start over* |
| `audio_track_id` / `audio_track_index` | The profile's preferred audio track, resolved from the series preference, then the profile's audio-language setting, then the library override | Exactly that track |
`progress_persistence` separates the live session clock from durable resume
ownership. Omission (or `server`) means session progress may update the item's
resume/history normally. `client` keeps heartbeats, route diagnostics, and live
session state intact but suppresses those durable writes because the client
persists its own item-global timeline (for example through `/sync/progress`). A
client choosing that mode must send `start_position` explicitly, including
explicit `0`; the server never substitutes saved resume state for it.
Both are settled *before* planning, not after. This is not an implementation
detail a client can ignore: the plan's timeline is cut at the start position
(§5), and the audio track is part of the plan's identity (§9), so a route chosen
for position zero and then seeked is a different route than the one the server
would have chosen for the resume point. A client that resolves resume state
itself and sends the position explicitly gets identical behaviour — that is the
supported way to override the server's policy.
### 2.3 `POST /playback/{session_id}/replan`
Asks for a different plan for an existing session — after a failure, or because
the user changed a track or the quality. Auth + `X-Profile-Id` required. Request
body cap: 256 KiB. Body: `ReplanRequestV3`
([`replan-request.schema.json`](../design/schemas/playback-v3/v3/replan-request.schema.json)).
| Status | Code | Meaning |
| --- | --- | --- |
| `200` | — | `DecisionResponseV3`, plan or terminal |
| `400` | `bad_request` | Malformed or failed validation |
| `401` | `unauthorized` | No authenticated user, or no `X-Profile-Id` |
| `403` | `forbidden` | The session belongs to another user or profile |
| `404` | `playback_session_not_found` | No such session, or its session has ended |
| `409` | `stale_playback_plan` | `failed_plan_id` is not the session's current plan, `playback_attempt_id` is not the session's attempt, or a newer replacement is already active |
| `409` | `idempotency_key_reused` | This `replan_request_id` was used for a different replan |
| `409` | `replan_in_progress` | A replan for this session holds the lease right now |
| `503` | `replan_capacity_exhausted` | Server-wide concurrent replan limit (8) reached; retryable |
| `500` | `internal_error` | Store outage |
Note that a missing profile is `401` here, where start answers `400` — start
validates the body first and reports the header as one more field problem, while
replan treats identity as a precondition. Neither is retryable, so the
difference does not change client behaviour.
Checks run in this order: auth → body decode and validation → concurrency slot →
session lock → attempt lookup → ownership → attempt match → live session → lease.
A client that sees `503` therefore knows nothing was read or written for its
session, and can retry the identical request unchanged. Note that validation
comes before every session lookup: a malformed body against a session that does
not exist answers `400`, not `404`.
**Leases.** A replan takes a 15-second lease on the session. A second request
carrying the *same* `replan_request_id` while the lease is in flight gets `409
replan_in_progress`; once the original completes, the same id replays its
response verbatim. This is what makes a client's retry-on-timeout safe.
Note the deliberate asymmetry with start: a store outage during replan is `500`,
never `404`. Clients tear playback down on session-not-found, so a transient
store failure must read as retryable rather than as the session having vanished.
### 2.4 `POST /playback/route-events`
Reports what happened on the device. Auth + `X-Profile-Id` required. Request body
cap: **32 KiB**. Body: a single `RouteEventV3`, not a batch
([`route-event.schema.json`](../design/schemas/playback-v3/v3/route-event.schema.json)).
| Status | Code | Meaning |
| --- | --- | --- |
| `202` | — | Accepted. **No response body.** |
| `400` | `bad_request` | Malformed or failed validation |
| `401` | `unauthorized` | No authenticated user or no profile |
| `403` | `forbidden` | The session or attempt belongs to another profile, or the referenced session/attempt does not exist |
| `429` | `event_rate_limited` | 120 events/attempt/minute or 600/user/minute exceeded |
| `500` | `internal_error` | Store outage |
The checks run in that order: auth, then body decode and validation, then the
rate limit, and only then the session-ownership lookup. The
limiter sits in front of the ownership lookup deliberately — it exists to bound
store reads as much as writes, so it has to precede the read that would establish
ownership.
Two consequences for clients. A `429` means "drop this event," never "retry it";
the events are diagnostics and losing one costs nothing. And an unknown session
is `403`, not `404` — the handler does not distinguish "not yours" from "not
there." A store outage during that lookup is `500`, so a `403` genuinely means
the event will never be accepted and should be dropped rather than retried.
A terminal decision returned by `POST /playback/start` has a durable attempt but
no playback session or plan. The client reports it with `event: "terminal"`,
the start request's `playback_attempt_id`, and no `session_id`, `plan_id`,
`plan_attempt_id`, or `plan_attempt_key`. The server authorizes the event through
the persisted attempt ownership and returns `202`.
Event names are the eleven in §7.4. `diagnostics` is a string→string map, capped
at 32 entries, and the server keeps only the keys on its allowlist (§7.5),
truncating each value to 256 characters. Unknown keys are dropped silently; a
client sending them is not an error, it just achieves nothing.
---
## 3. Capability evidence tiers
The hardest problem in this protocol is that clients lie — not maliciously, but
because platform APIs vary in how much they actually know. Android can enumerate
`MediaCodecList` and answer "this exact decoder supports H.264 High@4.1 at 8-bit
up to 1920×1080@60". A browser can only answer `isTypeSupported("video/mp4;
codecs=avc1.640028")` → true. Apple can attest that VideoToolbox handles a codec
family but not enumerate levels.
So a client declares *how it knows*, per media type, and the server applies a
different strictness to each tier. `video_evidence` and `audio_evidence` are
required and are one of:
| Tier | Who reports it | What the server does with it |
| --- | --- | --- |
| `exact` | Android (`MediaCodecList`) | Full strict validation. The server walks `video_decode[]` and requires a hardware entry matching codec, profile, level, bit depth, and every `max_*` bound. Only this tier can earn a validated audio **passthrough** claim. |
| `platform_attested` | Apple (VideoToolbox) | Same walk, but profile and level are **skipped** — the platform attests the codec family rather than enumerating modes. All other bounds still apply. |
| `declared` | Web (`isTypeSupported`) | Flat list match only: `codecs_video` / `codecs_video_hardware` membership. No `video_decode[]` walk. |
Four rules follow from the table and are easy to get wrong:
**A flat claim without backing detail is a refusal, not a pass.** On `exact` and
`platform_attested`, if a codec appears in `codecs_video` but no `video_decode[]`
entry names that codec with `hardware: true`, the source is *not* eligible for a
direct route. The plan is downgraded and carries the decision reason
`evidence_insufficient_for_direct` plus the matching degradation warning, which
distinguishes "your evidence didn't support this" from "your device said no." A
client advertising a strict tier must populate `video_decode[]`; the flat lists
alone earn it nothing. On `declared` the flat lists are the whole mechanism, so
that tier never produces this signal.
Note the precise trigger: the signal fires only when *no* entry named the codec.
If an entry matched the codec but the source exceeded one of its bounds — a
4K file against a `max_height: 1080` decoder — that is a real device limit, and
the plan is downgraded with no evidence warning. The two cases mean different
things and a client should not conflate them in its telemetry.
**An omitted bound means "unconstrained", not "unknown".** Within a
`video_decode[]` entry, an empty `profiles`, `levels`, or `bit_depths` list and a
zero `max_width` / `max_height` / `max_frame_rate` / `max_bitrate_kbps` are each
*skipped*, not failed. An entry that names a codec and nothing else therefore
claims that decoder handles every variant of it. That is a strong claim, and on
`exact` it is the client's job not to make it carelessly: the server will honour
it and hand back a direct route. Report what the platform actually enumerated.
The three list bounds are also not matched the same way, which matters when
populating them:
| Field | Match |
| --- | --- |
| `profiles` | Case-insensitive string equality against the source profile |
| `levels` | **At-least**: any listed level ≥ the source level passes |
| `bit_depths` | Exact integer equality |
So a decoder that tops out at H.264 level 4.1 may report `[41]` and still
validate a level-3.0 stream, while a decoder that handles 8- and 10-bit must
list both — `[10]` alone rejects an 8-bit source. Levels use the integer form
(4.1 → `41`).
**Every validated video route requires complete routing metadata.** Before any
tier logic runs, the server requires video codec, bit depth, width, height,
frame rate, and bitrate. Profile and level are decoder bounds instead: an
`exact` entry that supplies either bound cannot validate a source whose matching
probe value is absent or unknown, while an omitted bound keeps the explicit
“unconstrained” meaning above. This permits server adaptation of sources such as
VP9 whose probe reports an unknown level without allowing that sentinel to
satisfy a concrete client limit. A source missing the routing fields is
ineligible for any route and this case is *not* reported as
`evidence_insufficient_for_direct` — the client's evidence was never the
problem.
**Passthrough requires `exact` audio evidence.** A validated passthrough claim
(bitstreaming E-AC-3/TrueHD to a receiver) additionally requires the
`layout_aware_passthrough` feature in `client_features`, the codec listed in
`audio_passthrough.passthrough_codecs`, and a matching
`audio_passthrough.entries[]` whose `channel_counts` and `layouts` cover the
source. Only a client that can enumerate real sink layouts — the Android audio
HAL — can supply that. `platform_attested` and `declared` audio evidence still
qualify for ordinary decode/copy routes; they simply cannot earn
`claims.audio.passthrough = true`.
**HDR is decided against the output, not the decoder.** `output.hdr_details` (the
display or receiver actually attached) takes precedence over
`client_capabilities.hdr_details` (what the device could do in principle). A
source whose dynamic range is recorded as `hdr_unknown` — legacy rows that only
stored a file-level HDR boolean — is treated as HDR10 when the output supports
HDR10, and the plan carries the `hdr_range_assumed_hdr10` degradation warning.
Refusing to play those outright would be worse than an assumption the client is
told about.
The web client does not promote the generic high-dynamic-range media query to a
format claim, and it does not gate format claims on it either. Decoder capability
and active-output HDR are separate facts — browsers tone-map HDR content onto SDR
outputs, and Safari 26 reports `dynamic-range: standard` even on an XDR display —
so the media query survives only as the best-effort `hdr` output boolean. Format
claims come from exact shape probes matched to the bytes the remux delivers:
HDR10 requires Media Capabilities support for Silo's progressive 2160p HEVC
Main10, Rec. 2020, PQ, SMPTE ST 2086 shape, probed under exactly the `hvc1`
sample entry because the explicit v3 HDR10 strip remux labels its output `hvc1`
(legacy and automatic strip paths retain FFmpeg's default `hev1`). Dolby Vision
requires a definitive media-element answer for exactly `dvh1.05.06` or
`dvh1.08.06`, because the preserve remux tags its output `dvh1`. An answer only
for the other spelling (`hev1`/`dvhe`) is evidence for a file Silo never sends
and earns no claim. Both claims are scoped to `progressive`: they
are cleared from `original_http` and `hls` because those delivery paths were not
tested by the same probe. An HDR10 claim can carry `hdr10_max_width`, `hdr10_max_height`,
`hdr10_max_frame_rate`, and `hdr10_max_bitrate_kbps`; these ceilings keep a
successful format probe from admitting an untested stream class.
---
## 4. Deliveries
A delivery is *how bytes reach the player*. The server works in four values; the
client negotiates in three classes.
| Server `delivery` | Client class | What it is |
| --- | --- | --- |
| `original_http` | `original_http` | The source file, byte-for-byte, over HTTP with range support |
| `server_remux_progressive` | `progressive` | Repackaged into a new container, streamed as one chunked response |
| `server_remux_hls` | `hls` | Repackaged into HLS segments; codecs untouched |
| `server_transcode_hls` | `hls` | Re-encoded and segmented |
`client_playback_context.deliveries` is keyed by **class**, because a client's
answer to "can you play HLS" does not differ between a remux and a transcode —
the same player component handles both. The server folds its four values into
three when checking eligibility, and reports the specific one it chose in the
plan.
Each `deliveries` entry describes one class:
| Field | Meaning |
| --- | --- |
| `enabled` | The client is *willing* to use this class right now (user setting, network policy) |
| `supported_on_device` | The client is *able* to — the platform has a player for it at all |
| `failure_reason` | Optional free text explaining a `false` above; diagnostics only |
| `containers`, `video_codecs`, `audio_decode_codecs` | Flat lowercase name lists |
| `audio_passthrough_codecs` | Bitstream-out candidates; only ever honoured under the `exact` tier (§3) |
| `max_channels` | Optional ceiling applied to audio routing |
| `hdr_details` | Optional per-class HDR support, overriding the device-level value |
| `subtitles` | Six booleans: `embedded_text`, `sidecar_text`, `ass_styling`, `embedded_bitmap`, `sidecar_bitmap`, `font_attachments` |
| `features` | Class-scoped feature strings |
| `auth_header_refresh` | The client can re-fetch stream auth headers without restarting playback |
| `validated_claims` | Claims the client asserts it has verified for this class |
| `transformations` | Client-executed transformations offered for this class (§11) |
Both booleans must be true for the class to be eligible; they are separate
because "the user turned HLS off" and "this device has no HLS player" call for
different degradation warnings and different diagnostics. A class the client
omits entirely is unavailable — the server will not guess.
`stream.header_refresh` tells the client what to do when the stream URL's auth
expires: `none` means the URL is stable for the session, `session` means
re-request headers from `header_refresh_url` rather than restarting playback.
---
## 5. The timeline model
The single most common client bug in v2 was assuming the player's zero and the
source's zero are the same instant. In v3 they usually are not, and the plan says
so explicitly.
`timeline` carries four numbers that a client must keep distinct:
- `source_start_seconds` — where in the *media* this plan begins.
- `stream_origin_seconds` — the source position that the *transport's* byte-zero
corresponds to.
- `player_start_seconds` — where the client should seek the player after load.
- `timeline_offset_seconds` — what to add to a player position to get a source
position.
Three shapes exist:
**Direct and transcoded routes** hand the player a complete timeline.
`stream_origin` and `timeline_offset` are `0`, `player_start` is the requested
position, `can_seek_anywhere` is true when the runtime is known, and
`seek_restoration` is `player_position` — the client seeks locally.
**Copy remux over HLS** is served from FFmpeg's live, still-growing playlist,
which starts at the preceding keyframe selected by FFmpeg's input seek.
`stream_origin` and `timeline_offset` both equal that resolved source position,
while `player_start` is the requested position minus the resolved origin so the
client advances past copied pre-roll. `seek_window_start_seconds` is the resolved
origin, and **`seek_window_end_seconds` is deliberately absent**. An open end
marks the window as incomplete; combined with `can_seek_anywhere: false` it
routes every seek back through the server as a reanchor (§6), which is correct
because the playlist has no bytes for positions FFmpeg has not reached yet.
`seek_restoration` is `source_position`.
**Progressive remux** is a freshly generated chunked response with no byte-range
support, so it uses the same resolved keyframe origin and player pre-roll offset;
the window is open-ended and seeks go through the server.
### `source.duration_seconds`
The media's full runtime, and nothing else. Specifically:
- It is **not** `total − source_start`. It does not shrink because the plan
starts mid-file.
- It is **not** adjusted by `timeline_offset_seconds`.
- It is **omitted** rather than sent as `null` when the server does not know it,
because a client coercing `null` to a numeric default would read it as zero.
- A client **must not** substitute the playback engine's reported duration for
it. On an HLS copy remux the engine reports the length produced so far, not the
runtime — using it makes the scrubber grow while the user watches.
---
## 6. Replan
Replan is the only way a plan changes. It covers both "that didn't work" and
"the user asked for something else," and the distinction matters to the server.
`operation` is one of six:
| Operation | Meaning | Requires |
| --- | --- | --- |
| `failure_recovery` | The plan failed on the device | `failure.classification` |
| `seek_failure_recovery` | A seek failed | `failure.classification` |
| `seek_reanchor` | Move a server-anchored timeline to a new position | — |
| `track_change` | The user picked a different audio or subtitle track | — |
| `quality_change` | The user picked a rung from `available_qualities` | non-empty `quality_preference` |
| `output_change` | The active display/output capabilities changed | — |
The asymmetry is intentional. A seek reanchor is a timeline operation, not a
failed recipe — a classification is still accepted from older callers but never
selects seek semantics. A track or quality change is not a failure at all, so
demanding a classification would force clients to invent one. But
`quality_change` *must* name the rung it wants: an empty `quality_preference`
normalizes to `auto`, which is a different user intent than the menu selection
the operation models, so the server rejects it rather than silently doing
something else.
**Intent operations behave differently from failure recovery.**
`track_change`, `quality_change`, and `output_change` keep the previous route
eligible because nothing established that its recipe failed: neither attempted-
key history nor the failed-plan exclusion applies. The first two replace what
were separate v2 endpoints (an audio PATCH and a client-posted transcode start).
A client may therefore be handed back a plan it has already tried — that is
correct here, and a client must not treat a repeated `plan_attempt_key` as a
loop.
When such an operation actually changes something — the request's tracks,
quality, or output capabilities differ from what the session currently has —
the server also tries to return to the *requested* edition rather than staying
on whatever alternate version a previous fallback landed on, since the newly
requested intent may fit the original file again. That is a preference, not a
guarantee: if the requested edition no longer resolves or fails its preflight,
the healthy active alternate is kept. Track identities are remapped only when
the edition really changes, because remapping within one file would degrade an
exact selection to a best-match lookup and could silently move a listener off a
commentary track.
Omitting `quality_preference` on any replan preserves the session's current
preference; sending it replaces that preference. A track change therefore does
not silently reset `original` or a pinned rung, and failure recovery does not
discard the viewer's requested quality unless the client explicitly asks it to.
Clients should still send the current preference when they know it, so their
intent remains explicit in diagnostics.
For failure recovery, `attempted_plan_keys` is the loop guard. The client sends
back every `plan_attempt_key` it has already tried for this attempt (up to 16);
the server will not hand back a plan whose key is in that list. `attempt_count`
(1–8) bounds the whole recovery chain. Together they mean a device that fails
every route reaches a terminal instead of cycling forever.
Failure, seek, and quality replans may omit unchanged track identities. The
server overlays only identities present in those requests and preserves the
durable selected subtitle otherwise. Only `operation: "track_change"` gives an
omitted `selected_tracks.subtitle` the explicit meaning "subtitles off". A
fallback to another media version must remap the selected subtitle; if no
equivalent exists, it returns terminal reason `subtitle_unavailable_in_version`
instead of silently continuing with subtitles off.
`local_mutations` (up to 8 entries, 64 chars each) reports client-side
adjustments — a transport reopen, a PCM decode fallback — that change the
effective route without changing the plan. They feed the attempt key, so a plan
retried after a local mutation is a *different* attempt and is not blocked by the
loop guard.
A seek-scoped recovery refuses to accept new capability or device evidence: a
seek is not an authority boundary for replacing the client's declared abilities
mid-session.
---
## 7. Registries
### 7.1 Decision reasons
Why the server picked this route. Informational; clients may log or display but
must not branch on an unrecognized value.
`validated_original_playback`, `container_normalization`, `audio_adaptation`,
`hls_audio_adaptation`, `hls_packaging_required`, `subtitle_burn_in_required`,
`client_dv7_to_dv81`, `client_dv7_to_hdr10`, `evidence_insufficient_for_direct`,
and the quality reasons `quality_original`, `quality_auto_source`,
`quality_fixed_rung`, `quality_device_limit`, `quality_bandwidth_limit`,
`quality_metered_limit`, `quality_bandwidth_cap`.
### 7.2 Degradation warnings
The plan will play, but something the user might notice was given up.
| Code | Meaning |
| --- | --- |
| `hdr_range_assumed_hdr10` | Source range unknown; treated as HDR10 |
| `dolby_vision_removed` | DV metadata stripped |
| `dolby_vision_strip_unsupported_by_source` | DV could not be stripped |
| `dolby_vision_enhancement_layer_discarded` | FEL/MEL dropped, base layer kept |
| `audio_converted` | Audio re-encoded rather than copied |
| `subtitle_burn_in` | Subtitles rendered into the video |
| `quality_reduction_unavailable` | Requested rung could not be produced |
| `quality_preference_normalized` | Unknown `quality_preference` normalized to `auto` |
| `bandwidth_cap_applied` | `bandwidth_cap_kbps` limited the selection |
| `evidence_insufficient_for_direct` | Evidence tier blocked a direct route |
### 7.3 Terminal reasons
Playback will not proceed. `terminal.retryable` says whether trying again could
help. Delivered inside a `201` (start) or `200` (replan), never a 4xx.
*Planner:* `adaptation_exhausted`, `adaptation_unavailable`,
`client_hls_unsupported`, `conversion_tool_unavailable`,
`hdr_transcode_unsupported`, `no_alternate_version`,
`source_metadata_incomplete`, `source_unavailable`,
`audio_conversion_unsupported`, `video_conversion_unsupported`,
`dv_conversion_unsupported`, `transcoding_disabled`,
`subtitle_conversion_unsupported`. When a video adaptation is forced solely by a
subtitle burn-in requirement and cannot execute, the terminal is
`subtitle_conversion_unsupported` naming the subtitle rather than the underlying
HDR, 4K, or transcode-policy reason — deselecting the subtitle restores playback.
*Subtitle policy:* `subtitle_burn_in_source_unsupported`,
`subtitle_codec_unsupported`, `subtitle_track_invalid`,
`subtitle_track_unavailable`, `subtitle_unavailable_in_version`.
*Transport and session:* `internal_error`, `session_expired`,
`subtitle_artifact_unavailable`, `capacity_unavailable`,
`audio_transcoding_disabled`,
`transcode_start_failed`, `transcode_node_unavailable`,
`transcode_node_capability_unavailable`, `track_unavailable`,
`invalid_seek_position`, `invalid_replan`, `seek_reanchor_route_changed`,
`seek_reanchor_recipe_unavailable`,
`seek_reanchor_intent_mismatch`, `seek_failure_recovery_intent_mismatch`,
`policy_denied`.
### 7.4 Route event names
`plan_selected`, `plan_invalidated`, `plan_failed`, `first_frame`, `terminal`,
`stopped`, `runtime_correction_applied`, `runtime_correction_succeeded`,
`runtime_correction_failed`, `seek_reanchor_requested`, `seek_reanchored`.
### 7.5 Diagnostics allowlist
Route-event `diagnostics` keys the server retains. Everything else is dropped;
every value is truncated to 256 characters.
`decoder_name`, `decoder_init_ms`, `first_frame_ms`, `device_model`,
`requested_quality`, `effective_quality`, `pcm_recovery`, `retry_outcome`,
`replan_request_id`, `video_mime`, `video_codecs`, `video_width`, `video_height`,
`color_transfer`, `color_range`, `error_code`, `error_code_name`, `error_cause`,
`transformation_name`, `transformation_version`, `transformation_stage`,
`input_dv_profile`, `output_dv_profile`, `rpu_converted_count`,
`rpu_failed_count`, `el_nal_dropped_count`, `sample_count`,
`transform_buffer_peak_bytes`, `requested_media_file_id`,
`effective_media_file_id`, `audio_output_mode`, `audio_mime`, `audio_channels`,
`audio_decoder_name`, `correction_id`, `correction_stage`, `network_transport`,
`network_metered`, `network_validated`, `bandwidth_estimate_kbps`,
`link_downstream_kbps`, `target_source_position_seconds`, `reason`.
---
## 8. Track identity and the subtitle ordinal space
Every track is addressed as `file:{media_file_id}:{kind}:{ordinal}` — for
example `file:42:audio:0`. When a client sends both an id and an index they must
agree; the server rejects a disagreeing pair rather than picking one.
Subtitles occupy a single **combined ordinal space** spanning three sources, in
three dense consecutive ranges:
1. **External** sidecar files, in catalog order — ordinals `0 … E-1`
2. **Embedded** container streams, in container stream order — `E … E+M-1`
3. **Downloaded** subtitles, in `created_at` order — `E+M … E+M+D-1`
The space is dense and gap-free, and this is load-bearing. A track that has no
sidecar representation the stream handler can serve — a DVD or DVB bitmap stream
— **keeps its ordinal** and is published with `delivery: "burn_in_only"` and no
`url`. Omitting it would leave a hole, and any client deriving the
downloaded-track base by counting published URLs would then undercount and
address the wrong track. (That was a real bug; this rule is the fix.)
`playback_plan.subtitle.inventory` is the authoritative list. A client selects a
track by echoing an entry's `track_id` or `combined_index`. It must never derive
an ordinal by counting tracks, summing array lengths, or taking `max(index)+1`.
Each entry carries `source` (`external` | `embedded` | `downloaded`), `delivery`
(`sidecar` | `burn_in_only`), the `forced` / `default` / `hearing_impaired`
flags, a `url` when deliverable, and a `font_bundle_url` for embedded ASS tracks
with attachments. `default` reflects the source container's own default flag, so
only embedded and external tracks can carry it — a downloaded subtitle is never
`default`. `url` is present only on `sidecar` tracks, and only once a session
exists to scope it to — but it does not depend on the current selection: a start
or replan that resolves to `subtitle.mode: "off"` still publishes every sidecar
entry with its fetchable `url`, so a client can build its full subtitle menu
without first asking for a plan it does not want.
`subtitle.mode` is `off`, `render` (client draws the sidecar), `convert` (server
transcodes it to a client-renderable format first — always to WebVTT, served as
`text/vtt` at a `.vtt` URL), or `burn_in` (rendered into the video, which forces
a transcode).
The sidecar URL suffix is part of the representation contract, not decoration.
An embedded `hdmv_pgs_subtitle`/PGS sidecar is lossless binary PGS at a `.sup`
URL with `application/octet-stream`; cached full-track responses support `HEAD`
and byte ranges. Text conversion is always WebVTT at `.vtt`, while lossless
ASS/SSA uses `.ass`. A suffix that does not match the selected track or a valid
conversion is rejected with `415` rather than returning bytes of a different
type under the requested extension.
---
## 9. Plan identity
The server mints both identifiers. **Clients treat both as opaque, case-sensitive
tokens and never implement either identity algorithm.** Their wire prefixes and
lengths are validation syntax, not a derivation recipe.
`plan_id` identifies the server's playback decision. It is stable when the same
attempt produces the same source, delivery, recipe, tracks, subtitle mode,
transformations, applied quirks, and recipe revision. A change to any of those
inputs produces a different identity.
`plan_attempt_key` is the replan loop guard for a plan as attempted on one output
route with a set of client-reported local mutations. The server canonicalizes
order-insensitive inputs internally. The client:
1. stores the exact key from `playback_plan.plan_attempt_key`;
2. echoes it unchanged as `plan_attempt_key` when reporting that plan;
3. adds the unchanged token to `attempted_plan_keys` after the plan fails; and
4. never case-folds, parses, truncates, hashes, or synthesizes a replacement.
`internal/playback/testdata/protocol_v3/attempt_keys.json` contains opaque
cross-message vectors: a server-emitted token, the exact replan echo, and the
loop-rejection result. The generator computes the server token internally but
does not publish its preimage.
---
## 10. Quality
`playback_plan.available_qualities` is the menu. The client renders it and, on
selection, sends a `quality_change` replan with the entry's `label`. It does not
compute rungs.
The source rung is always present, labelled `original`, with
`preserves_source: true`. Transcode rungs are added only below the source's own
height, and only when HLS is available to the client, transcoding is enabled,
4K transcoding is permitted for a 4K source, and the source is not HDR. Ladder
bitrates:
| Rung | kbps |
| --- | --- |
| 2160p | 20000 |
| 1080p | 6000 |
| 720p | 2000 |
| 480p | 1500 |
Registry availability is deliberately *not* consulted when building the menu: a
capability check there could trigger lazy node fetches that a source-preserving
start must never pay for. A rung whose toolchain turns out to be missing degrades
to a retryable terminal at replan time instead.
Audio-only sources publish a single `original` rung — quality rungs are a video
concept.
`quality_preference` accepts `auto`, `original` (aliases `source`, `max`), and
`2160p` / `1080p` / `720p` / `480p` with the obvious aliases (`4k`, `uhd`, `fhd`,
`hd`, `sd`). An unrecognized value normalizes to `auto` and the response carries
the `quality_preference_normalized` warning rather than an error.
---
## 11. Transformations
A transformation is a named, versioned media operation with claims attached.
| Name | Executor | Recipe version | Promises | Claims |
| --- | --- | --- | --- | --- |
| `audio_to_aac` | `server` | `1` | — | `audio_decode` |
| `video_to_h264` | `server` | `2` | `sdr` output | `h264_decode` |
| `server_dv7_to_hdr10` | `server` | `1` | `hdr10` output | `dolby_vision_metadata_removed`, `hdr10_base_layer_preserved`, `enhancement_layer_discarded` |
They are advertised only if the installed FFmpeg actually has the required
capability, probed once at startup:
| Transformation | Probe |
| --- | --- |
| `server_dv7_to_hdr10` | `ffmpeg -bsfs` contains `dovi_rpu` |
| `audio_to_aac` | `ffmpeg -encoders` contains an `aac` encoder |
| `video_to_h264` | `ffmpeg -encoders` contains any of `libx264`, `h264_qsv`, `h264_vaapi`, `h264_nvenc`, `h264_videotoolbox` |
`GET /playback/capability` reports the *local* probe only. A deployment with
pooled transcode nodes may still plan an HLS route using a transformation those
nodes advertise but the local FFmpeg lacks, so the capability list is a floor,
not a ceiling — one more reason a client must not precompute routes from it.
An unavailable transformation is not silently skipped at plan time: it produces
its own terminal reason (`dv_conversion_unsupported`,
`audio_conversion_unsupported`, `video_conversion_unsupported`) so the client
learns which conversion was missing rather than seeing a generic refusal.
A client may advertise its *own* transformations in a delivery's
`transformations[]` with `executor: "client"` — Dolby Vision profile 7 → 8.1
conversion, for instance. The server accepts a client executor only when that
delivery is both `enabled` and `supported_on_device` **and** the request's
top-level `client_features` includes `client_video_transformations_v1`.
Duplicate `executor:name:recipe_version` triples are rejected. Client
transformations participate in plan identity exactly like server ones, so a
client that changes its transform version invalidates its prior attempt keys —
which is the intent.
---
## 12. Conformance
Three artifacts, in decreasing order of authority:
1. **`internal/playback/testdata/protocol_v3/`** — golden fixtures generated by
`cmd/playbackfixtures` from the live server types. `make playback-fixtures`
regenerates them; `make verify-playback-fixtures` fails CI if they are stale.
Android and Apple CI vendor these and compare against them as **opaque
expected output**. The direction of authority is inverted from where this
protocol started: the server defines the contract and clients prove
conformance, not the reverse. `conformance_matrix.json` covers the release
train's evidence tiers, delivery fallback chain, replan operations and
idempotency, quality ladder, audio-only route, HDR/Dolby Vision decisions,
audio adaptation and exact-layout passthrough, text/bitmap subtitle policy,
failure recovery, restart replay, capacity cleanup, output change, route
event limits, opaque loop guard, and legacy-upgrade response in one
generated cross-client corpus.
2. **`docs/design/schemas/playback-v3/`** — JSON Schemas for every wire body,
with valid and invalid fixtures. Every bound mirrors a server validator, so a
body these schemas reject is a body the server rejects.
`internal/playback/contract` enforces that the schemas, the Go types, and the
golden fixtures agree.
3. **This document** — the reasoning behind the above, and the normative source
for anything the schemas cannot express (idempotency semantics, the timeline
model, evidence-tier strictness, ordinal density).
A client that decodes the golden fixtures, echoes attempt keys byte-for-byte,
and round-trips a replan without computing an identity is conforming.
@@ -515,7 +515,7 @@ ASCII blocks render anywhere.
```text
┌─────────────────────────── CENTRAL (mode=server) ───────────────────────────┐
Client │ RequireAuth /playback/start /playback/transcode/start (mint token) │
Client │ RequireAuth /playback/start /playback/{sid}/replan (mint token) │
(web / android / │ native serve: /playback/transcode/{sid}/master.m3u8?st= /stream/{sid}?st= │
apple / jellyfin) │ Admin Stop/Terminate → write session-deny marker (event, not a timer) │
└───────────┬──────────────────────────┬───────────────────────┬──────────────┘
@@ -564,12 +564,13 @@ sequenceDiagram
participant C as Client
participant S as Central server (integrated)
participant PG as Postgres (access)
Note over C,S: transcode start
C->>S: POST /playback/transcode/start (RequireAuth)
Note over C,S: start — the plan decides this is a transcode
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
S->>PG: resolve file + access
S->>S: PlanPlaybackV3 → delivery = server_transcode_hls
S->>S: StartTranscode (ffmpeg) + RegisterTranscodeSession
S->>S: card = NewRecipeCard(opts); token = Sign(card.ToClaims(), 24h)
S-->>C: 202 manifestURL = /playback/transcode/{sid}/master.m3u8?st=TOKEN
S-->>C: 201 plan.url = /playback/transcode/{sid}/master.m3u8?st=TOKEN
Note over C,S: hot path — NO Postgres, NO lease (live session in memory)
C->>S: GET .../master.m3u8?st=TOKEN
S->>S: LoadOrReconstructSession → live hit → BuildPlaybackManifest (segments carry ?st)
@@ -599,9 +600,9 @@ sequenceDiagram
participant N as Proxy node
participant T as Transcode node
participant R as Redis
C->>S: POST /playback/transcode/start (RequireAuth)
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
S-->>C: plan.url = {N}/stream/transcode/TOKEN/master.m3u8
loop manifest + segments
C->>N: GET /stream/transcode/TOKEN/...
N->>N: verifyToken(TOKEN)
+5
View File
@@ -33,6 +33,11 @@ justification and falls back to the Deprecation/Sunset flow like anything else.
|---|---|---|
| String `GET`/`PUT`/`DELETE /api/v1/settings…`, the unknown-key extension bag, preference fields on profile/library/series DTOs | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | Replaced wholesale by the typed settings contract. Deferring past lock would mean carrying the Deprecation/Sunset surface *and* the untyped key bag — which lets any client invent a production setting the server stores unvalidated — through the deprecation window, which is the exact surface the contract exists to close. |
| The ten string-registry admin user-settings routes: `GET /api/v1/admin/users/{id}/settings`, `GET /api/v1/admin/users/{id}/settings/{key}`, `PUT /api/v1/admin/users/{id}/settings/{key}`, `DELETE /api/v1/admin/users/{id}/settings/{key}`, `GET /api/v1/admin/users/{id}/device-settings`, `GET /api/v1/admin/users/{id}/device-settings/{key}`, `DELETE /api/v1/admin/users/{id}/device-settings/{key}`, `PUT /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings` | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | The admin projection of the removal above: these routes read and wrote the string registry the contract replaces. Their canonical successors are `GET /api/v1/admin/users/{id}/settings/values` (every stored value across all scopes) and `PUT`/`DELETE /api/v1/admin/users/{id}/settings/values/{key}` at an explicit scope, sharing the session routes' validation. Keeping the string routes past lock would preserve an admin-only write path into the untyped bag after the user-facing one closed. |
| The legacy (pre-v3) request and response bodies of `POST /api/v1/playback/start`. The route stays; a body that does not declare `protocol_version: 3` now gets `426 client_upgrade_required` instead of a legacy plan | Playback protocol v3, [spec](playback-protocol-v3.md), [plan](../superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md) | v3 is a platform-neutral contract that moves route selection, quality laddering and track choice server-side. The legacy body carried the opposite model — the client posted a decision it had already made. Running both means every planner change has to be made twice, in two shapes that disagree about who decides, and the legacy shape is the one the client-specific bugs live in. A deprecation window would extend that duplication across the whole window for a protocol no shipping client will still speak by lock. |
| `POST /api/v1/playback/transcode/start` | Playback protocol v3, [spec](playback-protocol-v3.md) | Client-posted transcode recipes. Superseded by the `quality_change` replan operation: the client sends a label from `playback_plan.available_qualities` and the server picks the encode. Keeping the endpoint keeps a second, unvalidated way to start a transcode that bypasses plan identity entirely. |
| `PATCH /api/v1/playback/{session_id}/audio` | Playback protocol v3, [spec](playback-protocol-v3.md) | Superseded by the `track_change` replan operation, which changes the audio track *and* returns the resulting plan. The PATCH mutated the session without re-planning, so a track change that invalidated the route left the client playing a plan the server no longer agreed with. |
| `409 protocol_disabled` on `POST /api/v1/playback/route-events`, and the `"enabled": false` shape of `GET /api/v1/playback/capability` | Playback protocol v3, [spec](playback-protocol-v3.md) | Both described a server with v3 switched off. With v3 the only playback protocol that state cannot exist — "disabled" would mean "no playback at all". The `enabled` field itself is kept and is constant `true`, so clients that feature-detect against it keep working; only the negative shape and the status code go. |
| The draft-v3 platform-specific wire vocabulary: `ClientPlaybackContextV3.features`, `.platform`, and `.engines`; `PlanV3.engine`; `output_route_generation` in start, replan, output-context, and route-event bodies; Android device/build fields (`brand`, `device`, `product`, `soc_*`, `build_*`, `security_patch`, `sdk_int`, `abis`); and the `media3_only` / `detailed_decode_capabilities` feature tokens | Platform-neutral playback protocol v3, [spec](playback-protocol-v3.md), [plan](../superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md) | These names exposed one client's implementation as the cross-platform contract. Before v1 lock they are replaced by neutral delivery classes, evidence tiers, `device.platform` / `device.os_version` / bounded `platform_details`, opaque `output_context_id`, and top-level feature advertisement. Carrying both drafts through lock would force every client to translate Media3-specific aliases indefinitely and leave two conflicting sources of capability truth. |
Feature-detection precedent: clients discover which metadata providers (including the
built-in NFO provider, #216) apply to a library type via
+1 -1
View File
@@ -361,7 +361,7 @@ Borrows the *behavior* of the requests gate (status endpoint + per-call
guard that rereads settings) but stores keys in the general `server_settings`
store — requests uses its own dedicated table, which diagnostics doesn't
need. Key naming follows existing namespaces (`opslog.*`,
`audiobooks.enabled`, `playback.protocol_v3_enabled`):
`audiobooks.enabled`, `playback.*`):
- `diagnostics.uploads_enabled` (bool, default `false`)
- `diagnostics.max_bundle_bytes` (default 10 MiB compressed)
+223
View File
@@ -0,0 +1,223 @@
# Intro Skip Mode: Never / Ask / Always
Status: Proposed (spec + cross-repo plan)
Date: 2026-08-16
Repos affected: `silo-server` (contract, migration, web), `silo-android`, `silo-apple`
## Summary
Replace the boolean `playback.auto_skip_intro` with a three-way
`playback.intro_skip_mode` setting — `never`, `ask`, `always` — and define one
intro-skip prompt state machine that every Silo client implements identically:
web (browser), Android (phone, tablet, TV) and Apple (iPhone, iPad, tvOS, macOS).
The boolean already encodes two of the three modes: `true` is "count down, then
skip" and `false` is "show a Skip Intro button". `never` (no prompt at all) is
the only new behaviour, and it matches what Jellyfin's Intro Skipper offers.
The server change is a contract addition plus a compatibility mirror; the
client change is a rewrite of the prompt behaviour to the table below.
## Motivation
- There is no way to turn the prompt off. A viewer who wants to watch intros
still gets a button over the picture on every episode.
- The `always` behaviour today is *countdown-then-skip*: the intro plays for
five seconds while a bar fills. Viewers who chose "always" wanted the intro
gone; showing it for five seconds first is the wrong default. The right shape
is *skip immediately, offer an undo*.
- The three clients drifted. Android TV (silo-android#210) got a wall-clock
fill, focus-on-appear, and D-pad/Back rules that neither web nor Apple has.
Cross-platform consistency needs a written contract, not three
re-implementations of a verbal one.
## Setting
### `playback.intro_skip_mode`
| Field | Value |
| --- | --- |
| `value_schema` | `enum` — `never` ("Never"), `ask` ("Ask to skip"), `always` ("Skip automatically") |
| `default_value` | `ask` — identical to today's `auto_skip_intro = false` |
| `persistence` | `remote` |
| `allowed_scopes` | `profile`, `profile_device` (same as the boolean) |
| `resolution_order` | `profile_device`, `profile`, `default` |
| `category` / `label` | `playback` / "Skip intros" |
| `recommended_control` | `select` (the schema has no segmented control; clients that have one should use it) |
| `introduced_in` | 7 |
Contract revision bumps 6 → 7. `playback.auto_skip_intro` stays in the
manifest, marked `deprecated: true`, with `notes` pointing at the new key. It
is not removed in this cut: every shipped client reads it, and the profile DTO
carries it as a `NOT NULL` column.
### Migration
A Goose SQL migration copies every stored `playback.auto_skip_intro` row (all
scopes, all backends the row can live in) to `playback.intro_skip_mode` at the
same identity:
| stored boolean | new enum |
| --- | --- |
| `true` | `"always"` |
| `false` | `"ask"` |
`ON CONFLICT DO NOTHING` so a re-run, or a client that already wrote the enum,
never has its choice overwritten by the mirror. Nobody currently has `never`,
so no data becomes unrepresentable. Down: delete the `intro_skip_mode` rows.
### Compatibility mirror (one release)
While old clients are in the field, the two keys are kept in step at write
time so a preference set on one client shows up correctly on the others:
| Write | Server also writes |
| --- | --- |
| `PUT /settings/values/playback.auto_skip_intro` (any scope) | `intro_skip_mode` at the same identity: `true → always`, `false → ask` |
| `PUT /settings/values/playback.intro_skip_mode` (any scope) | `auto_skip_intro` at the same identity: `always → true`, else `false` |
| `DELETE` of either | delete the other at the same identity |
| Legacy `PUT /profiles/{id}` with `auto_skip_intro` | both keys at `profile` scope (extends `profiles_settings_sync`) |
| Legacy `PUT /settings/{key}` (runtime-key route) with `auto_skip_intro` | both keys, wherever that route lands them |
| Either key written or cleared at `profile` scope | the `user_profiles.auto_skip_intro` column, so `GET /profiles` stays truthful for old clients |
The column is the third copy of one preference, and it tracks every canonical
profile-scope change to either key — not only the enum. A column only the enum
could move would end up contradicting the row the caller stored: an enum write
sets it, and the boolean write or the `DELETE` that follows could not put it
back. A cleared row means "inherit again", so the column falls back to the
contract default with it. Device-scope writes leave it alone; it is
profile-wide, and one television's override is not the household's choice.
The lossy direction is deliberate: an old client sees `never` as `false`
(= ask) and shows the button. That is the least surprising degradation. An old
client that then flips the toggle overwrites `never` — acceptable for the
overlap window, and the reason the mirror is dropped once clients have moved.
Removal plan: once Android, Apple and web all read `intro_skip_mode`, a
follow-up removes the mirror, marks the boolean `deprecated` in the profile DTO
and stops writing it. The `user_profiles.auto_skip_intro` column is retired
with the other legacy profile playback columns, not on its own.
### `playback.auto_skip_credits`
Not changed here. It has the same shape and will want the same treatment
(`credits_skip_mode`); keeping it out keeps this change reviewable, and the
credits prompt has a different interaction (it competes with Next Up).
## Prompt behaviour
Terminology:
- **intro** — a `TimeRange` `[start, end)` from the server's chapter/marker
data, with a stable per-item key so "this intro" survives seeks.
- **inside** — playback position `p` with `start ≤ p < end`.
- **pill** — the single on-screen prompt. It has a **timer**, always
wall-clock, always the same length across platforms
(`INTRO_PROMPT_SECONDS = 5`; contract-visible so a future setting can drive it).
- **resolved** — the viewer has made a decision for this intro in this
playback session. A resolved intro never shows a pill again, including if
the viewer scrubs back into it.
- **Select** — click/tap on the pill, or Select/OK on a remote while the pill
is focused, or Enter/Space on web while focused.
- **Back** — remote Back, keyboard Escape, Android system back.
### `never`
Entering an intro does nothing. No pill, no skip. Chapter markers still render
on the timeline where the client already does so.
### `ask`
| Event | Outcome |
| --- | --- |
| Enter intro (not resolved) | Show pill **"Skip Intro"** with timer running. Pill takes focus on appear on focus-driven platforms (TV, keyboard). |
| Timer runs out | Pill hides. Intro keeps playing. Intro is **not** resolved — scrubbing back into it re-offers. |
| Select | Seek to `end`. Intro resolved. Pill hides. On TV, focus lands on the timeline/transport, never nowhere. |
| Back | Pill hides. Intro keeps playing. Intro resolved. Press is consumed (does not exit playback / close overlay); a second Back behaves normally. |
| D-pad / arrow / pointer move away | Focus moves as normal. **Timer keeps running.** Pill stays until timer ends. |
| Pause | Timer **freezes** at its current value; resumes from there on play. Pill stays visible. |
| Seek out of the intro | Pill hides, timer stops. Not resolved. |
| Seek back into the intro | Same as Enter (unless resolved). Timer restarts from full. |
| Playback stall / rebuffer < 1.5 s | Ignored: timer keeps running (see *Timing*). |
| Controls overlay hides/shows | Pill stays; it may reposition (e.g. drop toward the corner when transport hides). Never hidden by the overlay timeout. |
### `always`
| Event | Outcome |
| --- | --- |
| Enter intro (not resolved) | **Seek to `end` immediately.** Show pill **"Intro Skipped · Play Intro"** with timer running. Focus as in `ask`. |
| Timer runs out | Pill hides. Playback continues past the intro. Intro resolved. |
| Select | Seek to `start`. Intro resolved (so re-entering the range does not skip again). Pill hides. Focus lands on transport. |
| Back | Pill hides. Playback continues. Intro resolved. Press consumed. |
| Move focus away | Timer keeps running. |
| Pause | Timer freezes; resumes on play. |
| Seek by the viewer into a resolved intro | Nothing. The viewer asked for it. |
| Seek by the viewer into an unresolved intro | Same as Enter — skipped again, undo offered. This only happens on a fresh session or an item with several marked intros. |
The pill in `always` is the *undo* affordance, so unlike `ask` its timeout does
resolve the intro: the viewer was told it was skipped and let it go.
### Timing
- The timer is **wall-clock**. Compose scales `AnimationSpec` by the system
animator duration scale, `prefers-reduced-motion` can shorten CSS
transitions, and UIKit honours accessibility motion settings; a countdown to
an action must ignore all of those or the fill lies about when the action
fires. Decorative motion (fade in/out, reposition) may still honour them.
- Timer state and its visual (fill / ring / number) derive from the same
clock. Never run two timers.
- The timer starts when playback is actually running, not when the player is
still coming up, so the pill and the fill start together.
- A pause is a pause only after `PLAYBACK_PAUSE_GRACE_MS = 1500`; a shorter
`isPlaying == false` is a rebuffer and does not touch the timer. (This is
the `settlingFalseEdges` behaviour from silo-android#210; port it, don't
reinvent it.)
### Input handling
- Select / Back for the pill are handled at the **player root**, not on the
pill widget, so they work whether or not the pill is in the focus tree. TV
and keyboard both need this.
- While the pill is focused, Select acts on it with a single press — no
navigate-then-press.
- The pill must never steal focus during a scrub or from an open menu. If the
viewer is mid-interaction when the intro starts, the pill appears unfocused
and the timer runs anyway.
- Pointer platforms (web, phone, tablet): the pill is a normal button; hover
and tap behave as Select. Tap outside the pill is not Back.
### Presentation
- One pill, lower-right of the video, above the transport cluster while
controls are visible; drops toward the corner when they hide.
- Fill creeps left → right and reaches full exactly when the timer ends
(`ask`: pill hides; `always`: pill hides). Same colour language everywhere:
dimmed when unfocused, lit when focused, solid on press.
- Copy: **"Skip Intro"** / **"Intro Skipped · Play Intro"**. Localised via
each client's normal string tables; the semantics are fixed.
- Disappears instantly on Select/Back; fades on timeout.
## Client rollout
Each client reads `playback.intro_skip_mode` from the effective settings
endpoint, falling back to `auto_skip_intro` only if the server contract
revision is < 7. Settings UI shows a three-way control (segmented where the platform has one) labelled
Never / Ask to skip / Skip automatically; the old switch goes away.
| Repo | Work |
| --- | --- |
| `silo-server` (this change) | Manifest rev 7, migration, write mirror, bindings regen. Web keeps showing only the `auto_skip_intro` switch: two controls for one preference would disagree the moment either was touched, and the player cannot honour `never` yet. The web control and the player behaviour above land together in a follow-up in the same repo. |
| `silo-android` | `IntroAutoSkipController` (shared KMP, drives phone + TV) gains the three modes and the `Skipped/OfferingUndo` state; `TvIntroAutoSkipBanner` / `IntroAutoSkipBanner` render both copies; settings screens swap the switch for a segmented control; `SettingKeys.kt` regenerated by `make settings-bindings`. |
| `silo-apple` | Same state machine in the shared player; tvOS focus rules; iOS/macOS pointer rules; `SettingKeys.generated.swift` regenerated. |
Conformance: the tables above are the test oracle. Each client's state-machine
tests should assert them case-for-case (silo-android's
`IntroAutoSkipControllerTest` is the template), so a divergence is a failing
test rather than a bug report.
## Open questions
- Should `ask`'s timeout be configurable (a `playback.intro_prompt_seconds`
key like `next_up_prompt_seconds`)? Not in this cut; the constant is named
so it can become one.
- Whether the same tri-state should reach `auto_skip_credits` in the same
release train, or wait for the Next Up interaction to be specified.
@@ -39,6 +39,22 @@
"audio_underruns": {
"type": "integer",
"description": "Audio underrun count."
},
"session_id": {
"type": "string",
"description": "Playback session identifier for correlation with operational logs."
},
"play_method": {
"type": "string",
"description": "Selected playback delivery method such as direct, remux, or transcode."
},
"reason": {
"type": "string",
"description": "Playback route, interruption, or lifecycle reason."
},
"position_ms": {
"type": "integer",
"description": "Current playback position in milliseconds."
}
},
"focus": {
@@ -67,12 +83,44 @@
"duration_ms": {
"type": "integer",
"description": "Request duration in milliseconds."
},
"outcome": {
"type": "string",
"description": "Request outcome such as success, http_error, transport_error, or cancelled."
},
"error_code": {
"type": "string",
"description": "Stable error classification for a failed request."
},
"attempt": {
"type": "integer",
"description": "Attempt ordinal for a retried request."
}
},
"lifecycle": {
"state": {
"type": "string",
"description": "App lifecycle state."
},
"phase": {
"type": "string",
"description": "Startup or lifecycle phase identifier."
},
"duration_ms": {
"type": "integer",
"description": "Elapsed duration for the phase in milliseconds."
},
"outcome": {
"type": "string",
"description": "Result of the phase, such as success, failure, or skipped."
},
"reason": {
"type": "string",
"description": "Classification for the phase outcome."
},
"launch_type": {
"type": "string",
"description": "Launch classification, such as cold or warm."
}
},
"crash": {
@@ -0,0 +1,8 @@
The canonical playback protocol v3 wire contract lives in `v3/`.
Clients vendor the schema files and fixtures from this directory; each schema is self-contained and refers only to its own `$defs`, so a client can vendor one body at a time.
The wire fixtures under `v3/fixtures/valid/` are copies of the matching server-generated golden bodies in `internal/playback/testdata/protocol_v3/`; treat them as opaque expected output rather than hand-edited examples, and regenerate them from the server rather than editing them in place. The golden directory also contains non-wire cross-message fixtures such as `attempt_keys.json`, `subtitle_inventory.json`, and `conformance_matrix.json`; clients vendor those directly from the generator output.
`error-response.schema.json` describes the common non-2xx envelope; its golden fixture is the exact `426 client_upgrade_required` response to a legacy start body.
Every bound in these schemas mirrors a server validator, so a body this directory rejects is a body the server rejects.
Identity fields the server computes — `plan_id`, `plan_attempt_key` — are opaque to clients: store them, echo them back on replan and route events, and never parse or recompute them.
Within v3, changes are additive only: new optional fields, and new enum values after coordinated client support.
Breaking changes require a new versioned contract directory and explicit server support.
@@ -0,0 +1,123 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/capability-response.schema.json",
"title": "Silo Playback Protocol v3 Capability Response",
"description": "Response body of GET /api/v1/playback/capability, mirroring CapabilityResponseV3 in internal/playback/protocol_v3.go. A client reads this before its first start request so it plans against what the server actually advertises instead of a compiled-in assumption. Protocol v3 is the server's only playback protocol, so enabled is always true; there is no disabled response shape.",
"type": "object",
"required": [
"enabled",
"protocol_versions",
"features",
"deliveries",
"transformations"
],
"properties": {
"enabled": {
"type": "boolean",
"const": true
},
"protocol_versions": {
"type": "array",
"description": "Protocol versions this server speaks. A client picks the highest it supports rather than assuming a single value, which is what keeps a future v4 from breaking v3 clients.",
"minItems": 1,
"uniqueItems": true,
"contains": {
"const": 3
},
"items": {
"type": "integer"
}
},
"features": {
"type": "array",
"description": "Server feature flags. The list grows additively within v3, so an unrecognized entry is expected and must be ignored, not treated as an error.",
"minItems": 8,
"uniqueItems": true,
"allOf": [
{ "contains": { "const": "playback_plan_v3" } },
{ "contains": { "const": "neutral_playback_v3_contract_v1" } },
{ "contains": { "const": "layout_aware_passthrough" } },
{ "contains": { "const": "playback_route_diagnostics" } },
{ "contains": { "const": "device_quirks_v1" } },
{ "contains": { "const": "seek_reanchor_v1" } },
{ "contains": { "const": "direct_stream_resume_v1" } },
{ "contains": { "const": "plan_source_duration_v1" } }
],
"items": {
"type": "string"
}
},
"deliveries": {
"type": "array",
"description": "Serving routes the planner may return. The list contains the server's currently executable delivery classes and grows additively.",
"minItems": 4,
"uniqueItems": true,
"allOf": [
{ "contains": { "const": "original_http" } },
{ "contains": { "const": "server_remux_progressive" } },
{ "contains": { "const": "server_remux_hls" } },
{ "contains": { "const": "server_transcode_hls" } }
],
"items": {
"type": "string",
"enum": [
"original_http",
"server_remux_progressive",
"server_remux_hls",
"server_transcode_hls"
]
}
},
"transformations": {
"type": "array",
"description": "Server-executed transformations available to the planner. Client-executed transformations are declared by the client in its start request, not listed here.",
"items": {
"$ref": "#/$defs/transformation"
}
},
"reason": {
"type": "string",
"description": "Reserved optional diagnostic for a future non-rollout condition. It does not disable protocol v3; enabled remains true."
}
},
"$defs": {
"transformation": {
"type": "object",
"required": [
"name",
"executor",
"recipe_version",
"validated_claims"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"executor": {
"type": "string",
"description": "Capability responses advertise transformations installed on the server. Client-executed transformations are declared in requests instead.",
"const": "server"
},
"recipe_version": {
"type": "string",
"description": "Identity of the recipe, not a semantic version to compare. A client matches it exactly or declines the transformation.",
"minLength": 1,
"maxLength": 32
},
"validated_claims": {
"type": "array",
"description": "Neutral statements about the bytes the transformation produces, so a client reasons about the output without knowing the server's toolchain.",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
}
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}
@@ -0,0 +1,798 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/decision-response.schema.json",
"title": "Silo Playback Protocol v3 Decision Response",
"description": "Response body of POST /api/v1/playback/start and POST /api/v1/playback/{session_id}/replan. Shapes mirror DecisionResponseV3 and PlanV3 in internal/playback/protocol_v3.go. A playable outcome carries playback_plan; adaptation_unavailable carries terminal. Enum lists are the values the server emits today and grow additively within v3, so a client must tolerate an unrecognized member of a described-but-open list rather than treating the plan as unusable.",
"type": "object",
"required": [
"protocol_version",
"server_features",
"outcome"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"server_features": {
"type": "array",
"description": "Additive feature tokens supported by this server. Unknown entries must be ignored.",
"items": {
"type": "string"
}
},
"outcome": {
"type": "string",
"enum": [
"playable",
"adaptation_unavailable"
]
},
"session_id": {
"type": "string"
},
"playback_plan": {
"$ref": "#/$defs/plan"
},
"terminal": {
"$ref": "#/$defs/terminal"
}
},
"oneOf": [
{
"properties": {
"outcome": {
"const": "playable"
}
},
"required": [
"session_id",
"playback_plan"
],
"not": {
"required": [
"terminal"
]
}
},
{
"properties": {
"outcome": {
"const": "adaptation_unavailable"
}
},
"required": [
"terminal"
],
"allOf": [
{
"not": {
"required": [
"session_id"
]
}
},
{
"not": {
"required": [
"playback_plan"
]
}
}
]
}
],
"$defs": {
"terminal": {
"type": "object",
"description": "Why no route exists. `retryable` distinguishes a condition that may clear (a missing toolchain, incomplete metadata) from one that will not for this client and source.",
"required": [
"reason",
"message",
"retryable"
],
"properties": {
"reason": {
"type": "string",
"minLength": 1
},
"message": {
"type": "string"
},
"retryable": {
"type": "boolean"
}
},
"additionalProperties": true
},
"track_identity": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "Combined-ordinal track identity `file:{file_id}:{audio|subtitle}:{ordinal}`."
},
"index": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": true
},
"transformation": {
"type": "object",
"required": [
"name",
"executor",
"recipe_version",
"validated_claims"
],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"executor": {
"type": "string",
"description": "Who runs it: `server` before the bytes leave the server, `client` as the client's own responsibility.",
"enum": [
"client",
"server"
]
},
"recipe_version": {
"type": "string",
"minLength": 1
},
"validated_claims": {
"type": "array",
"description": "Neutral statements about the produced bytes, not client-framework decoder names.",
"items": {
"type": "string"
}
}
},
"additionalProperties": true
},
"plan": {
"type": "object",
"required": [
"protocol_version",
"plan_id",
"plan_attempt_key",
"delivery",
"stream",
"timeline",
"selected_tracks",
"effective_recipe",
"claims",
"subtitle",
"transformations",
"applied_quirks",
"runtime_corrections",
"available_qualities",
"degradation_warnings",
"decision_reason",
"requested_media_file_id",
"effective_media_file_id",
"source",
"subtitle_fidelity_policy"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"plan_id": {
"type": "string",
"description": "Deterministic server-computed plan identity, `plan:` followed by 32 hex characters. Opaque to clients: echo it, never parse or recompute it.",
"pattern": "^plan:[0-9a-f]{32}$"
},
"plan_attempt_key": {
"type": "string",
"description": "Server-computed opaque loop-prevention token, always `v3:` prefixed. Clients store the keys of attempted plans and echo them in attempted_plan_keys on replan; they never compute keys themselves.",
"pattern": "^v3:",
"minLength": 8
},
"session_id": {
"type": "string"
},
"expires_at": {
"type": "string",
"format": "date-time"
},
"delivery": {
"type": "string",
"description": "The plan's serving route. Clients negotiate the coarser delivery classes (original_http, progressive, hls); these are the finer-grained plan outcomes those classes fold onto.",
"enum": [
"original_http",
"server_remux_progressive",
"server_remux_hls",
"server_transcode_hls"
]
},
"stream": {
"$ref": "#/$defs/stream"
},
"timeline": {
"$ref": "#/$defs/timeline"
},
"selected_tracks": {
"type": "object",
"properties": {
"audio": {
"$ref": "#/$defs/track_identity"
},
"subtitle": {
"$ref": "#/$defs/track_identity"
}
},
"additionalProperties": true
},
"effective_recipe": {
"$ref": "#/$defs/effective_recipe"
},
"claims": {
"$ref": "#/$defs/claims"
},
"subtitle": {
"$ref": "#/$defs/subtitle_decision"
},
"transformations": {
"type": "array",
"items": {
"$ref": "#/$defs/transformation"
}
},
"applied_quirks": {
"type": "array",
"items": {
"$ref": "#/$defs/applied_quirk"
}
},
"runtime_corrections": {
"type": "array",
"description": "Client-executed corrections this plan depends on. A client that cannot run a listed correction must not select the plan.",
"items": {
"type": "string"
}
},
"available_qualities": {
"type": "array",
"description": "Server ladder rungs valid for this source and client, so a client renders a quality menu without owning a bitrate table. The entry with preserves_source true pins the source rung.",
"minItems": 1,
"contains": {
"type": "object",
"required": ["label", "preserves_source"],
"properties": {
"label": { "const": "original" },
"preserves_source": { "const": true }
}
},
"items": {
"$ref": "#/$defs/available_quality"
}
},
"degradation_warnings": {
"type": "array",
"items": {
"$ref": "#/$defs/degradation_warning"
}
},
"decision_reason": {
"type": "string",
"minLength": 1
},
"requested_media_file_id": {
"type": "integer",
"minimum": 1
},
"effective_media_file_id": {
"type": "integer",
"minimum": 1,
"description": "Differs from requested_media_file_id when the server routed to an alternate edition of the same title. Track identities in this plan belong to the effective file."
},
"source": {
"$ref": "#/$defs/source_descriptor"
},
"subtitle_fidelity_policy": {
"type": "string",
"enum": [
"require_authored_fidelity",
"allow_simplified_rendering"
]
}
},
"additionalProperties": true
},
"stream": {
"type": "object",
"required": [
"url",
"protocol",
"headers",
"header_refresh"
],
"properties": {
"url": {
"type": "string",
"minLength": 1
},
"protocol": {
"type": "string",
"enum": [
"http_progressive",
"hls"
]
},
"container": {
"type": "string"
},
"mime_type": {
"type": "string",
"description": "What the transport will actually serve. A declared-tier client probes this before attaching a source buffer, so an audio-only remux advertises audio/mp4 rather than video/mp4."
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"header_refresh": {
"type": "string",
"enum": [
"none",
"session"
]
},
"header_refresh_url": {
"type": "string"
}
},
"additionalProperties": true
},
"timeline": {
"type": "object",
"description": "Anchoring for the delivered stream. source_start_seconds is the position in the source, player_start_seconds where the player should start within the delivered timeline; the two differ when the stream is re-anchored at a seek point.",
"required": [
"source_start_seconds",
"stream_origin_seconds",
"player_start_seconds",
"timeline_offset_seconds",
"can_seek_anywhere",
"seek_restoration"
],
"properties": {
"source_start_seconds": {
"type": "number"
},
"stream_origin_seconds": {
"type": "number"
},
"player_start_seconds": {
"type": "number"
},
"timeline_offset_seconds": {
"type": "number"
},
"seek_window_start_seconds": {
"type": "number"
},
"seek_window_end_seconds": {
"type": "number"
},
"can_seek_anywhere": {
"type": "boolean"
},
"seek_restoration": {
"type": "string",
"description": "Which clock a resume position is expressed in: `player_position` for a full-timeline stream, `source_position` for one re-anchored at a seek point.",
"enum": [
"player_position",
"source_position"
]
}
},
"additionalProperties": true
},
"effective_recipe": {
"type": "object",
"description": "What the delivered bytes actually are, after any adaptation. Fields the server has no value for are omitted rather than sent as zero.",
"properties": {
"video_codec": {
"type": "string"
},
"audio_codec": {
"type": "string"
},
"width": {
"type": "integer"
},
"height": {
"type": "integer"
},
"frame_rate": {
"type": "number"
},
"bitrate_kbps": {
"type": "integer"
},
"dynamic_range": {
"type": "string"
},
"audio_channels": {
"type": "integer"
},
"audio_layout": {
"type": "string"
}
},
"additionalProperties": true
},
"claims": {
"type": "object",
"description": "What the server promises about the delivered bytes. A client renders its HDR/passthrough/subtitle affordances from these rather than re-deriving them from the source descriptor.",
"required": [
"video",
"audio",
"subtitles"
],
"properties": {
"video": {
"type": "object",
"required": [
"hdr10",
"hdr10_plus",
"hlg",
"dolby_vision"
],
"properties": {
"hdr10": {
"type": "boolean"
},
"hdr10_plus": {
"type": "boolean"
},
"hlg": {
"type": "boolean"
},
"dolby_vision": {
"type": "boolean"
},
"dolby_vision_reason": {
"type": "string"
}
},
"additionalProperties": true
},
"audio": {
"type": "object",
"required": [
"passthrough",
"atmos_preserved"
],
"properties": {
"codec": {
"type": "string"
},
"passthrough": {
"type": "boolean"
},
"atmos_preserved": {
"type": "boolean"
},
"dts_variant": {
"type": "string"
},
"reason": {
"type": "string"
}
},
"additionalProperties": true
},
"subtitles": {
"type": "object",
"required": [
"ass_styling_preserved",
"bitmap_overlay",
"bitmap_sidecar"
],
"properties": {
"ass_styling_preserved": {
"type": "boolean"
},
"bitmap_overlay": {
"type": "boolean"
},
"bitmap_sidecar": {
"type": "boolean"
},
"reason": {
"type": "string"
}
},
"additionalProperties": true
}
},
"additionalProperties": true
},
"subtitle_decision": {
"type": "object",
"required": [
"mode",
"inventory"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"render",
"convert",
"burn_in"
]
},
"track_id": {
"type": "string"
},
"artifact": {
"type": "object",
"description": "The prepared sidecar for the selected track. timing_origin_seconds is the source position the artifact's cue timestamps are relative to.",
"required": [
"url",
"mime_type",
"format",
"timing_origin_seconds"
],
"properties": {
"url": {
"type": "string",
"minLength": 1
},
"mime_type": {
"type": "string"
},
"format": {
"type": "string"
},
"timing_origin_seconds": {
"type": "number"
}
},
"additionalProperties": true
},
"inventory": {
"type": "array",
"description": "Authoritative, dense, gap-free combined-ordinal track list for the effective source: external tracks first, then embedded, then downloaded. A client selects by echoing an entry's track_id or combined_index and never derives an ordinal by counting, summing track arrays, or taking max(index)+1.",
"items": {
"$ref": "#/$defs/subtitle_inventory_item"
}
}
},
"additionalProperties": true
},
"subtitle_inventory_item": {
"type": "object",
"required": [
"track_id",
"combined_index",
"source",
"forced",
"default",
"hearing_impaired",
"delivery"
],
"properties": {
"track_id": {
"type": "string",
"minLength": 1
},
"combined_index": {
"type": "integer",
"minimum": 0
},
"source": {
"type": "string",
"enum": [
"external",
"embedded",
"downloaded"
]
},
"codec": {
"type": "string"
},
"language": {
"type": "string"
},
"label": {
"type": "string"
},
"forced": {
"type": "boolean"
},
"default": {
"type": "boolean"
},
"hearing_impaired": {
"type": "boolean"
},
"delivery": {
"type": "string",
"description": "`sidecar` tracks are fetchable from their published url. A `burn_in_only` track has no client-fetchable representation and carries no url, but still occupies its ordinal and stays selectable — omitting it would leave a hole that makes every later ordinal wrong.",
"enum": [
"sidecar",
"burn_in_only"
]
},
"url": {
"type": "string"
},
"font_bundle_url": {
"type": "string",
"description": "Attachment bundle needed to render an embedded ASS/SSA track with its authored typesetting."
}
},
"additionalProperties": true
},
"applied_quirk": {
"type": "object",
"description": "A device-specific decision the planner made on top of the generic route. registry_revision identifies the quirk registry the decision came from.",
"required": [
"id",
"registry_revision",
"action"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"registry_revision": {
"type": "string",
"maxLength": 128
},
"action": {
"type": "string"
},
"reason": {
"type": "string"
}
},
"additionalProperties": true
},
"available_quality": {
"type": "object",
"required": [
"label",
"preserves_source"
],
"properties": {
"label": {
"type": "string",
"minLength": 1
},
"height": {
"type": "integer"
},
"bitrate_kbps": {
"type": "integer"
},
"preserves_source": {
"type": "boolean"
}
},
"additionalProperties": true
},
"degradation_warning": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "string",
"minLength": 1
},
"message": {
"type": "string"
}
},
"additionalProperties": true
},
"source_descriptor": {
"type": "object",
"required": [
"media_file_id",
"hdr10_plus",
"dv_enhancement_layer"
],
"properties": {
"media_file_id": {
"type": "integer",
"minimum": 1
},
"duration_seconds": {
"type": "number",
"description": "Full runtime of this source, independent of where the delivery's timeline is anchored. Absent means the server does not know the runtime; it is omitted rather than null so clients that coerce null to a numeric default do not read it as zero. Do not substitute the playback engine's reported duration — on an HLS copy remux the engine reports the length produced so far."
},
"container": {
"type": "string"
},
"video_codec": {
"type": "string"
},
"video_profile": {
"type": "string"
},
"video_level": {
"type": "integer"
},
"bit_depth": {
"type": "integer"
},
"color_range": {
"type": "string"
},
"width": {
"type": "integer"
},
"height": {
"type": "integer"
},
"frame_rate": {
"type": "number"
},
"bitrate_kbps": {
"type": "integer"
},
"dynamic_range": {
"type": "string",
"description": "Values the server emits today: sdr, hdr10, hdr10_plus, hlg, dolby_vision, and hdr_unknown for legacy rows that recorded only a file-level HDR flag."
},
"hdr10_plus": {
"type": "boolean"
},
"dolby_vision_profile": {
"type": "integer"
},
"dolby_vision_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"dv_bl_compat_id": {
"type": "integer"
},
"dv_enhancement_layer": {
"type": "string",
"enum": [
"none",
"mel",
"fel",
"unknown"
]
},
"audio_codec": {
"type": "string"
},
"audio_channels": {
"type": "integer"
},
"audio_layout": {
"type": "string"
},
"video_copy_unsafe": {
"type": "boolean"
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}
@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/error-response.schema.json",
"title": "Silo Playback Protocol v3 Error Response",
"description": "Common non-2xx response body for playback-v3 endpoints. Clients branch on error and may display message. The valid fixture pins the 426 response returned to a legacy start body.",
"type": "object",
"required": ["error", "message"],
"properties": {
"error": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_]*$"
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 1024
}
},
"additionalProperties": true
}
@@ -0,0 +1,113 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "failure_recovery",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"attempted_plan_keys": [
"v3:0000000000000001"
],
"attempt_count": 9,
"quality_preference": "auto",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"failure": {
"classification": "network_degraded"
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,99 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
},
"bandwidth_estimate_kbps": 99
}
@@ -0,0 +1,162 @@
{
"protocol_version": 3,
"client_features": [
"feature_00",
"feature_01",
"feature_02",
"feature_03",
"feature_04",
"feature_05",
"feature_06",
"feature_07",
"feature_08",
"feature_09",
"feature_10",
"feature_11",
"feature_12",
"feature_13",
"feature_14",
"feature_15",
"feature_16",
"feature_17",
"feature_18",
"feature_19",
"feature_20",
"feature_21",
"feature_22",
"feature_23",
"feature_24",
"feature_25",
"feature_26",
"feature_27",
"feature_28",
"feature_29",
"feature_30",
"feature_31",
"feature_32",
"feature_33",
"feature_34",
"feature_35",
"feature_36",
"feature_37",
"feature_38",
"feature_39",
"feature_40",
"feature_41",
"feature_42",
"feature_43",
"feature_44",
"feature_45",
"feature_46",
"feature_47",
"feature_48",
"feature_49",
"feature_50",
"feature_51",
"feature_52",
"feature_53",
"feature_54",
"feature_55",
"feature_56",
"feature_57",
"feature_58",
"feature_59",
"feature_60",
"feature_61",
"feature_62",
"feature_63",
"feature_64"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,45 @@
{
"protocol_version": 3,
"playback_attempt_id": "attempt-golden-0001",
"session_id": "11111111-1111-4111-8111-111111111111",
"plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"event": "first_frame",
"output_context_id": "7",
"diagnostics": {
"key_00": "0",
"key_01": "1",
"key_02": "2",
"key_03": "3",
"key_04": "4",
"key_05": "5",
"key_06": "6",
"key_07": "7",
"key_08": "8",
"key_09": "9",
"key_10": "10",
"key_11": "11",
"key_12": "12",
"key_13": "13",
"key_14": "14",
"key_15": "15",
"key_16": "16",
"key_17": "17",
"key_18": "18",
"key_19": "19",
"key_20": "20",
"key_21": "21",
"key_22": "22",
"key_23": "23",
"key_24": "24",
"key_25": "25",
"key_26": "26",
"key_27": "27",
"key_28": "28",
"key_29": "29",
"key_30": "30",
"key_31": "31",
"key_32": "32"
}
}
@@ -0,0 +1,8 @@
{
"enabled": false,
"protocol_versions": [],
"features": [],
"deliveries": [],
"transformations": [],
"reason": "disabled"
}
@@ -0,0 +1,110 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "failure_recovery",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"attempted_plan_keys": [
"v3:0000000000000001"
],
"attempt_count": 1,
"quality_preference": "auto",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,61 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,3 @@
{
"error": "client_upgrade_required"
}
@@ -0,0 +1,98 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,113 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "failure_recovery",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "0000000000000001",
"attempted_plan_keys": [
"v3:0000000000000001"
],
"attempt_count": 1,
"quality_preference": "auto",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"failure": {
"classification": "network_degraded"
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,5 @@
{
"protocol_version": 3,
"server_features": [],
"outcome": "playable"
}
@@ -0,0 +1,113 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"detail_00": "0",
"detail_01": "1",
"detail_02": "2",
"detail_03": "3",
"detail_04": "4",
"detail_05": "5",
"detail_06": "6",
"detail_07": "7",
"detail_08": "8",
"detail_09": "9",
"detail_10": "10",
"detail_11": "11",
"detail_12": "12",
"detail_13": "13",
"detail_14": "14",
"detail_15": "15",
"detail_16": "16"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,98 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "-leading-dash-attempt",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,110 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "quality_change",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"attempted_plan_keys": [
"v3:0000000000000001"
],
"attempt_count": 1,
"quality_preference": "",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,98 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 31536001,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,15 @@
{
"protocol_version": 3,
"playback_attempt_id": "attempt-golden-0001",
"session_id": "11111111-1111-4111-8111-111111111111",
"plan_id": "plan:golden-0001",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:0000000000000001",
"event": "buffering",
"output_context_id": "7",
"diagnostics": {
"decoder_name": "c2.android.avc.decoder",
"first_frame_ms": "412",
"video_mime": "video/avc"
}
}
@@ -0,0 +1,98 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "best_effort",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,98 @@
{
"protocol_version": 2,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,51 @@
{
"enabled": true,
"protocol_versions": [
3
],
"features": [
"playback_plan_v3",
"neutral_playback_v3_contract_v1",
"layout_aware_passthrough",
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
"deliveries": [
"original_http",
"server_remux_progressive",
"server_remux_hls",
"server_transcode_hls"
],
"transformations": [
{
"name": "audio_to_aac",
"executor": "server",
"recipe_version": "1",
"validated_claims": [
"audio_decode"
]
},
{
"name": "server_dv7_to_hdr10",
"executor": "server",
"recipe_version": "1",
"validated_claims": [
"dolby_vision_metadata_removed",
"hdr10_base_layer_preserved",
"enhancement_layer_discarded"
]
},
{
"name": "video_to_h264",
"executor": "server",
"recipe_version": "2",
"validated_claims": [
"h264_decode"
]
}
]
}
@@ -0,0 +1,181 @@
{
"protocol_version": 3,
"server_features": [
"playback_plan_v3",
"neutral_playback_v3_contract_v1",
"layout_aware_passthrough",
"playback_route_diagnostics",
"device_quirks_v1",
"seek_reanchor_v1",
"output_change_v1",
"direct_stream_resume_v1",
"plan_source_duration_v1"
],
"outcome": "playable",
"session_id": "11111111-1111-4111-8111-111111111111",
"playback_plan": {
"protocol_version": 3,
"plan_id": "plan:478677870860e5e5108c18bff749b34b",
"plan_attempt_key": "v3:f0144c47fa349e3e",
"session_id": "11111111-1111-4111-8111-111111111111",
"expires_at": "2030-01-01T00:00:00Z",
"delivery": "original_http",
"stream": {
"url": "/stream/11111111-1111-4111-8111-111111111111",
"protocol": "http_progressive",
"container": "mp4",
"mime_type": "video/mp4",
"headers": {},
"header_refresh": "none"
},
"timeline": {
"source_start_seconds": 12.5,
"stream_origin_seconds": 0,
"player_start_seconds": 12.5,
"timeline_offset_seconds": 0,
"can_seek_anywhere": true,
"seek_restoration": "player_position"
},
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"effective_recipe": {
"video_codec": "h264",
"audio_codec": "aac",
"width": 1920,
"height": 1080,
"frame_rate": 23.976023976023978,
"bitrate_kbps": 8000,
"dynamic_range": "sdr",
"audio_channels": 2,
"audio_layout": "stereo"
},
"claims": {
"video": {
"hdr10": false,
"hdr10_plus": false,
"hlg": false,
"dolby_vision": false
},
"audio": {
"codec": "aac",
"passthrough": false,
"atmos_preserved": false,
"reason": "client_decode_supported"
},
"subtitles": {
"ass_styling_preserved": false,
"bitmap_overlay": false,
"bitmap_sidecar": false
}
},
"subtitle": {
"mode": "off",
"inventory": [
{
"track_id": "file:42:subtitle:0",
"combined_index": 0,
"source": "external",
"codec": "srt",
"language": "eng",
"label": "English",
"forced": false,
"default": false,
"hearing_impaired": false,
"delivery": "sidecar",
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/0.vtt?file_id=42"
},
{
"track_id": "file:42:subtitle:1",
"combined_index": 1,
"source": "embedded",
"codec": "ass",
"language": "eng",
"label": "English (Signs)",
"forced": true,
"default": false,
"hearing_impaired": false,
"delivery": "sidecar",
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1.ass?file_id=42",
"font_bundle_url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1/fonts?file_id=42"
},
{
"track_id": "file:42:subtitle:2",
"combined_index": 2,
"source": "embedded",
"codec": "pgs",
"language": "jpn",
"label": "Japanese",
"forced": false,
"default": false,
"hearing_impaired": false,
"delivery": "sidecar",
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/2.sup?file_id=42"
},
{
"track_id": "file:42:subtitle:3",
"combined_index": 3,
"source": "embedded",
"codec": "dvd_subtitle",
"language": "fre",
"label": "French",
"forced": false,
"default": false,
"hearing_impaired": false,
"delivery": "burn_in_only"
},
{
"track_id": "file:42:subtitle:4",
"combined_index": 4,
"source": "downloaded",
"codec": "srt",
"language": "spa",
"label": "Spanish (downloaded)",
"forced": false,
"default": false,
"hearing_impaired": false,
"delivery": "sidecar",
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/4.vtt?file_id=42"
}
]
},
"transformations": [],
"applied_quirks": [],
"runtime_corrections": [],
"available_qualities": [
{
"label": "original",
"height": 1080,
"bitrate_kbps": 8000,
"preserves_source": true
}
],
"degradation_warnings": [],
"decision_reason": "validated_original_playback",
"requested_media_file_id": 42,
"effective_media_file_id": 42,
"source": {
"media_file_id": 42,
"duration_seconds": 7200,
"container": "mp4",
"video_codec": "h264",
"video_profile": "high",
"video_level": 41,
"bit_depth": 8,
"width": 1920,
"height": 1080,
"frame_rate": 23.976023976023978,
"bitrate_kbps": 8000,
"dynamic_range": "sdr",
"hdr10_plus": false,
"dv_enhancement_layer": "none",
"audio_codec": "aac",
"audio_channels": 2,
"audio_layout": "stereo"
},
"subtitle_fidelity_policy": "allow_simplified_rendering"
}
}
@@ -0,0 +1,4 @@
{
"error": "client_upgrade_required",
"message": "This server requires playback protocol v3. Update the app to continue."
}
@@ -0,0 +1,113 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "failure_recovery",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-golden-0001",
"failed_plan_id": "plan:478677870860e5e5108c18bff749b34b",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:f0144c47fa349e3e",
"attempted_plan_keys": [
"v3:f0144c47fa349e3e"
],
"attempt_count": 1,
"quality_preference": "auto",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"failure": {
"classification": "network_degraded"
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,15 @@
{
"protocol_version": 3,
"playback_attempt_id": "attempt-golden-0001",
"session_id": "11111111-1111-4111-8111-111111111111",
"plan_id": "plan:478677870860e5e5108c18bff749b34b",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:f0144c47fa349e3e",
"event": "first_frame",
"output_context_id": "7",
"diagnostics": {
"decoder_name": "c2.android.avc.decoder",
"first_frame_ms": "412",
"video_mime": "video/avc"
}
}
@@ -0,0 +1,99 @@
{
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"file_id": 42,
"profile_id": "profile-1",
"playback_attempt_id": "attempt-golden-0001",
"quality_preference": "original",
"subtitle_fidelity_preference": "compatible",
"start_position": 12.5,
"progress_persistence": "client",
"audio_track_id": "file:42:audio:0",
"audio_track_index": 0,
"metered": false,
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
}
@@ -0,0 +1,761 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/replan-request.schema.json",
"title": "Silo Playback Protocol v3 Replan Request",
"description": "Body of POST /api/v1/playback/{session_id}/replan. Bounds mirror ReplanRequestV3.Validate and validateCapabilitiesV3 in internal/playback/protocol_v3.go. STRING LENGTHS: every `maxLength` here is a character count, while the server enforces the same limit as a UTF-8 byte length (Go `len(string)`), so a value near a cap that contains multi-byte characters can satisfy this schema yet be rejected at ingest. JSON Schema cannot express byte length, so the divergence is documented rather than encoded.",
"type": "object",
"required": [
"protocol_version",
"playback_attempt_id",
"replan_request_id",
"failed_plan_id",
"plan_attempt_id",
"plan_attempt_key",
"attempt_count",
"client_capabilities",
"client_playback_context"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"client_features": {
"$ref": "#/$defs/feature_list"
},
"operation": {
"type": "string",
"description": "Omitted means failure_recovery, which keeps callers that predate the field on the ordinary recovery path. Seek operations are opt-in because both pin the current media version and user intent.",
"enum": [
"failure_recovery",
"seek_reanchor",
"seek_failure_recovery",
"track_change",
"quality_change",
"output_change"
],
"maxLength": 32
},
"playback_attempt_id": {
"$ref": "#/$defs/bounded_identifier"
},
"replan_request_id": {
"$ref": "#/$defs/bounded_identifier"
},
"failed_plan_id": {
"$ref": "#/$defs/opaque_plan_token"
},
"plan_attempt_id": {
"$ref": "#/$defs/opaque_plan_token"
},
"plan_attempt_key": {
"$ref": "#/$defs/plan_attempt_key"
},
"attempted_plan_keys": {
"type": "array",
"description": "Keys of the plans this attempt has already tried, echoed back verbatim so the server can refuse to hand out a plan that already failed on this output route.",
"maxItems": 16,
"items": {
"$ref": "#/$defs/plan_attempt_key"
}
},
"local_mutations": {
"type": "array",
"description": "Client-applied local plan mutations, so the server can fold them into the attempt key it computes for the failed plan. Clients never hash anything themselves.",
"maxItems": 8,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"attempt_count": {
"type": "integer",
"minimum": 1,
"maximum": 8
},
"quality_preference": {
"type": "string",
"description": "Free-form and deliberately unbounded, normalized the same way as on a start request. A quality_change operation must name a non-blank rung: an empty preference would silently mean \"auto\", which is a different user intent than the menu selection that operation models."
},
"position_seconds": {
"type": "number",
"minimum": 0,
"maximum": 31536000
},
"metered": {
"type": "boolean"
},
"bandwidth_estimate_kbps": {
"$ref": "#/$defs/bandwidth_kbps"
},
"bandwidth_cap_kbps": {
"$ref": "#/$defs/bandwidth_kbps"
},
"selected_tracks": {
"$ref": "#/$defs/selected_tracks"
},
"failure": {
"$ref": "#/$defs/failure"
},
"client_capabilities": {
"$ref": "#/$defs/client_capabilities"
},
"client_playback_context": {
"$ref": "#/$defs/client_playback_context"
}
},
"$defs": {
"bounded_identifier": {
"type": "string",
"description": "Opaque client-minted identifier. The server matches ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$, so the value is 8 to 128 characters and starts alphanumeric.",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$",
"minLength": 8,
"maxLength": 128
},
"opaque_plan_token": {
"type": "string",
"description": "Server-minted plan identity echoed back verbatim. Clients store and return these tokens and never parse or recompute them.",
"minLength": 8,
"maxLength": 128
},
"plan_attempt_key": {
"type": "string",
"description": "Server-computed opaque loop-prevention token. Always carries the `v3:` prefix; the server compares keys byte-for-byte, so case and whitespace are significant.",
"pattern": "^v3:",
"minLength": 8,
"maxLength": 128
},
"track_identity": {
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 128
},
"index": {
"$ref": "#/$defs/track_index"
}
},
"additionalProperties": true
},
"selected_tracks": {
"type": "object",
"properties": {
"audio": {
"$ref": "#/$defs/track_identity"
},
"subtitle": {
"$ref": "#/$defs/track_identity"
}
},
"additionalProperties": true
},
"failure": {
"type": "object",
"description": "Required for failure_recovery and seek_failure_recovery: both need a non-empty classification. Intent operations carry no failure.",
"properties": {
"classification": {
"type": "string",
"maxLength": 64
},
"message": {
"type": "string",
"maxLength": 512
},
"decoder_name": {
"type": "string",
"maxLength": 128
}
},
"additionalProperties": true
},
"track_index": {
"type": "integer",
"minimum": 0,
"maximum": 10000
},
"bandwidth_kbps": {
"type": "integer",
"minimum": 100,
"maximum": 1000000
},
"feature_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 128
}
},
"capability_value_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 128
}
},
"delivery_value_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 64
}
},
"capability_evidence": {
"type": "string",
"description": "How the capability facts were produced. `exact` is a real platform probe with per-codec profiles/levels/bounds, `platform_attested` a decoder attestation without profile enumeration, `declared` a boolean support statement. Planner strictness follows the tier.",
"enum": [
"exact",
"platform_attested",
"declared"
],
"maxLength": 32
},
"hdr_capabilities": {
"type": "object",
"properties": {
"hdr10": {
"type": "boolean"
},
"hdr10_plus": {
"type": "boolean"
},
"hlg": {
"type": "boolean"
},
"hdr10_max_width": {
"type": "integer",
"minimum": 0
},
"hdr10_max_height": {
"type": "integer",
"minimum": 0
},
"hdr10_max_frame_rate": {
"type": "number",
"minimum": 0
},
"hdr10_max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
},
"dolby_vision_profile_levels": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"required": [
"profile",
"max_level"
],
"properties": {
"profile": {
"type": "integer",
"minimum": 1
},
"max_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"bl_compatibility_ids": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 15
}
}
},
"additionalProperties": true
}
}
},
"allOf": [
{
"if": {
"anyOf": [
{ "required": ["hdr10_max_width"] },
{ "required": ["hdr10_max_height"] },
{ "required": ["hdr10_max_frame_rate"] },
{ "required": ["hdr10_max_bitrate_kbps"] }
]
},
"then": {
"required": ["hdr10"],
"properties": { "hdr10": { "const": true } }
}
}
],
"additionalProperties": true
},
"audio_passthrough": {
"type": "object",
"properties": {
"passthrough_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"spatializer_enabled": {
"type": "boolean"
},
"max_channels": {
"type": "integer",
"minimum": 0,
"maximum": 64
},
"entries": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"properties": {
"codec": {
"type": "string",
"maxLength": 64
},
"channel_counts": {
"type": "array",
"maxItems": 32,
"items": {
"type": "integer"
}
},
"layouts": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string"
}
}
},
"additionalProperties": true
}
}
},
"additionalProperties": true
},
"video_decode_capability": {
"type": "object",
"required": [
"codec"
],
"properties": {
"codec": {
"type": "string",
"minLength": 1
},
"decoder_name": {
"type": "string",
"maxLength": 128
},
"profiles": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 64
}
},
"levels": {
"type": "array",
"maxItems": 64,
"items": {
"type": "integer"
}
},
"bit_depths": {
"type": "array",
"maxItems": 64,
"items": {
"type": "integer"
}
},
"max_width": {
"type": "integer",
"minimum": 0
},
"max_height": {
"type": "integer",
"minimum": 0
},
"max_frame_rate": {
"type": "number",
"minimum": 0
},
"max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"hardware": {
"type": "boolean"
}
},
"additionalProperties": true
},
"client_capabilities": {
"type": "object",
"required": [
"video_evidence",
"audio_evidence"
],
"properties": {
"video_evidence": {
"$ref": "#/$defs/capability_evidence"
},
"audio_evidence": {
"$ref": "#/$defs/capability_evidence"
},
"codecs_video": {
"$ref": "#/$defs/capability_value_list"
},
"codecs_video_hardware": {
"$ref": "#/$defs/capability_value_list"
},
"codecs_audio": {
"$ref": "#/$defs/capability_value_list"
},
"containers": {
"$ref": "#/$defs/capability_value_list"
},
"max_resolution": {
"type": "string",
"maxLength": 32
},
"hdr": {
"type": "boolean"
},
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"audio_passthrough": {
"$ref": "#/$defs/audio_passthrough"
},
"video_decode": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/video_decode_capability"
}
}
},
"additionalProperties": true
},
"transformation_capability": {
"type": "object",
"required": [
"name",
"executor",
"recipe_version"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"executor": {
"type": "string",
"enum": [
"client",
"server"
]
},
"recipe_version": {
"type": "string",
"minLength": 1,
"maxLength": 32
},
"validated_claims": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
}
}
},
"additionalProperties": true
},
"delivery_capability": {
"type": "object",
"description": "One negotiated delivery class the client can execute. A `client` executor transformation is only accepted on a delivery that is enabled and supported_on_device, and only when client_features advertises client_video_transformations_v1; (executor, name, recipe_version) must be unique within the delivery.",
"properties": {
"enabled": {
"type": "boolean"
},
"supported_on_device": {
"type": "boolean"
},
"failure_reason": {
"type": "string"
},
"containers": {
"$ref": "#/$defs/delivery_value_list"
},
"video_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"audio_decode_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"audio_passthrough_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"max_channels": {
"type": "integer"
},
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"subtitles": {
"type": "object",
"properties": {
"embedded_text": {
"type": "boolean"
},
"sidecar_text": {
"type": "boolean"
},
"ass_styling": {
"type": "boolean"
},
"embedded_bitmap": {
"type": "boolean"
},
"sidecar_bitmap": {
"type": "boolean"
},
"font_attachments": {
"type": "boolean"
}
},
"additionalProperties": true
},
"features": {
"$ref": "#/$defs/delivery_value_list"
},
"auth_header_refresh": {
"type": "boolean"
},
"validated_claims": {
"$ref": "#/$defs/delivery_value_list"
},
"transformations": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "#/$defs/transformation_capability"
}
}
},
"additionalProperties": true
},
"client_playback_context": {
"type": "object",
"required": [
"protocol_version"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"form_factor": {
"type": "string",
"maxLength": 32
},
"app_version": {
"type": "string",
"maxLength": 64
},
"app_build": {
"type": "string",
"maxLength": 64
},
"app_channel": {
"type": "string",
"maxLength": 32
},
"device": {
"type": "object",
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
"properties": {
"platform": {
"type": "string",
"maxLength": 32
},
"os_version": {
"type": "string",
"maxLength": 128
},
"manufacturer": {
"type": "string",
"maxLength": 128
},
"model": {
"type": "string",
"maxLength": 128
},
"platform_details": {
"type": "object",
"maxProperties": 16,
"propertyNames": {
"minLength": 1,
"maxLength": 128
},
"additionalProperties": {
"type": "string",
"maxLength": 128
}
}
},
"additionalProperties": true
},
"output": {
"type": "object",
"properties": {
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"audio_passthrough": {
"$ref": "#/$defs/audio_passthrough"
},
"current_sink": {
"type": "string",
"maxLength": 128
},
"sink_type": {
"type": "string",
"maxLength": 128
},
"output_context_id": {
"type": "string",
"description": "Opaque token identifying the current output route. The server only ever compares it for equality, so any stable platform-native identity works and omitting it is valid.",
"maxLength": 128
}
},
"additionalProperties": true
},
"deliveries": {
"type": "object",
"description": "Keyed by delivery class: original_http, progressive, hls. A class the client omits is a class the planner will not route to.",
"maxProperties": 16,
"propertyNames": {
"maxLength": 64
},
"additionalProperties": {
"$ref": "#/$defs/delivery_capability"
}
}
},
"additionalProperties": true
}
},
"allOf": [
{
"$comment": "failure_recovery (the default when operation is omitted) and seek_failure_recovery require a non-empty failure classification; mirrors ReplanRequestV3.Validate in internal/playback/protocol_v3.go.",
"if": {
"anyOf": [
{
"not": {
"required": [
"operation"
]
}
},
{
"properties": {
"operation": {
"enum": [
"failure_recovery",
"seek_failure_recovery"
]
}
},
"required": [
"operation"
]
}
]
},
"then": {
"required": [
"failure"
],
"properties": {
"failure": {
"required": [
"classification"
],
"properties": {
"classification": {
"minLength": 1
}
}
}
}
}
},
{
"$comment": "quality_change must name the wanted rung; an empty preference would mean auto, a different user intent than the menu selection.",
"if": {
"properties": {
"operation": {
"const": "quality_change"
}
},
"required": [
"operation"
]
},
"then": {
"required": [
"quality_preference"
],
"properties": {
"quality_preference": {
"minLength": 1,
"pattern": "\\S"
}
}
}
},
{
"$comment": "Intent-only replans describe a user or output change and must not carry route-failure evidence.",
"if": {
"properties": {
"operation": {
"enum": [
"track_change",
"quality_change",
"output_change"
]
}
},
"required": [
"operation"
]
},
"then": {
"not": {
"required": [
"failure"
]
}
}
}
],
"additionalProperties": true
}
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/route-event.schema.json",
"title": "Silo Playback Protocol v3 Route Event",
"description": "Request body of POST /api/v1/playback/route-event: one event object, not a batch. Bounds mirror validRouteEventV3 in internal/api/handlers/playback_v3.go. The endpoint is telemetry, so the server rejects a malformed event rather than half-recording it; every bound here is a rejection, and the two behaviours that are not rejections — unknown diagnostics keys being dropped, long diagnostics values being truncated — are described rather than constrained.",
"type": "object",
"required": [
"protocol_version",
"playback_attempt_id",
"event"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"playback_attempt_id": {
"type": "string",
"description": "The attempt this event belongs to, correlating every plan the client tried for one user-visible playback start.",
"minLength": 8,
"maxLength": 128
},
"session_id": {
"type": "string",
"maxLength": 128
},
"plan_id": {
"type": "string",
"description": "Echoed from the plan being reported on. Opaque to clients.",
"maxLength": 128
},
"plan_attempt_id": {
"type": "string",
"maxLength": 128
},
"plan_attempt_key": {
"type": "string",
"description": "Echoed from the plan being reported on. Opaque to clients: never computed on the client side.",
"maxLength": 128
},
"event": {
"type": "string",
"description": "The complete v3 telemetry contract. New names are added additively within v3; the server rejects a name it does not know, so a client must not invent one.",
"enum": [
"plan_selected",
"plan_invalidated",
"plan_failed",
"first_frame",
"terminal",
"stopped",
"runtime_correction_applied",
"runtime_correction_succeeded",
"runtime_correction_failed",
"seek_reanchor_requested",
"seek_reanchored"
]
},
"failure_classification": {
"type": "string",
"description": "Neutral failure class, not a client-framework error string.",
"maxLength": 64
},
"fallback_reason": {
"type": "string",
"maxLength": 64
},
"applied_quirk_ids": {
"type": "array",
"description": "Quirks the client actually applied, so a registry entry can be measured against real outcomes rather than assumed to work.",
"maxItems": 16,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"quirk_registry_revision": {
"type": "string",
"maxLength": 128
},
"output_context_id": {
"type": "string",
"maxLength": 128
},
"diagnostics": {
"type": "object",
"description": "Free-form key/value detail. The server keeps only keys on its allowlist and silently drops the rest, and trims each surviving value to 256 characters, so an unknown key or a long value is not a rejection — but more than 32 entries is.",
"maxProperties": 32,
"additionalProperties": {
"type": "string"
}
}
},
"additionalProperties": true
}
@@ -0,0 +1,618 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/schemas/playback-v3/v3/start-request.schema.json",
"title": "Silo Playback Protocol v3 Start Request",
"description": "Body of POST /api/v1/playback/start. Bounds mirror StartRequestV3.NormalizeAndValidate and validateCapabilitiesV3 in internal/playback/protocol_v3.go. STRING LENGTHS: every `maxLength` here is a character count, while the server enforces the same limit as a UTF-8 byte length (Go `len(string)`), so a value near a cap that contains multi-byte characters can satisfy this schema yet be rejected at ingest. JSON Schema cannot express byte length, so the divergence is documented rather than encoded.",
"type": "object",
"required": [
"protocol_version",
"file_id",
"profile_id",
"playback_attempt_id",
"subtitle_fidelity_preference",
"client_capabilities",
"client_playback_context"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"client_features": {
"$ref": "#/$defs/feature_list"
},
"file_id": {
"type": "integer",
"minimum": 1
},
"profile_id": {
"type": "string",
"description": "Must equal the X-Profile-Id header on the request; the server rejects a mismatch.",
"minLength": 1
},
"playback_attempt_id": {
"$ref": "#/$defs/bounded_identifier"
},
"quality_preference": {
"type": "string",
"description": "Free-form and deliberately unbounded: the server normalizes an unrecognized value to \"auto\" and returns a quality_preference_normalized degradation warning rather than rejecting the request. The values that survive normalization unchanged are auto, original, 2160p, 1080p, 720p and 480p."
},
"subtitle_fidelity_preference": {
"type": "string",
"enum": [
"preserve",
"compatible"
],
"maxLength": 32
},
"start_position": {
"type": "number",
"description": "Where in the source to begin, in seconds. Omitting it asks the server for the profile's saved resume point (0 when there is none, when the item is already complete, or when the file is one part of a multipart item); sending 0 explicitly means start over. Resolved before planning, because the plan's timeline is cut at the start position.",
"minimum": 0,
"maximum": 31536000
},
"progress_persistence": {
"type": "string",
"description": "Who owns durable item resume/history persistence. Omission defaults to server. client keeps session heartbeats live but suppresses server item progress writes and requires an explicit start_position.",
"enum": [
"server",
"client"
],
"default": "server"
},
"audio_track_id": {
"description": "Omitting both audio fields asks the server for the profile's preferred track, resolved from the series preference, then the profile audio-language setting, then the library override. Sending either one selects exactly that track.",
"$ref": "#/$defs/track_id"
},
"audio_track_index": {
"$ref": "#/$defs/track_index"
},
"subtitle_track_id": {
"$ref": "#/$defs/track_id"
},
"subtitle_track_index": {
"$ref": "#/$defs/track_index"
},
"metered": {
"type": "boolean"
},
"bandwidth_estimate_kbps": {
"$ref": "#/$defs/bandwidth_kbps"
},
"bandwidth_cap_kbps": {
"$ref": "#/$defs/bandwidth_kbps"
},
"client_capabilities": {
"$ref": "#/$defs/client_capabilities"
},
"client_playback_context": {
"$ref": "#/$defs/client_playback_context"
}
},
"allOf": [
{
"if": {
"properties": {
"progress_persistence": {
"const": "client"
}
},
"required": [
"progress_persistence"
]
},
"then": {
"required": [
"start_position"
]
}
}
],
"$defs": {
"bounded_identifier": {
"type": "string",
"description": "Opaque client-minted identifier. The server matches ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$, so the value is 8 to 128 characters and starts alphanumeric.",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$",
"minLength": 8,
"maxLength": 128
},
"track_id": {
"type": "string",
"description": "Combined-ordinal track identity `file:{file_id}:{audio|subtitle}:{ordinal}`. When the matching index field is also sent the two must agree; the server rebuilds the identity from file_id and index and compares byte-for-byte.",
"maxLength": 128
},
"track_index": {
"type": "integer",
"minimum": 0,
"maximum": 10000
},
"bandwidth_kbps": {
"type": "integer",
"minimum": 100,
"maximum": 1000000
},
"feature_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 128
}
},
"capability_value_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 128
}
},
"delivery_value_list": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 64
}
},
"capability_evidence": {
"type": "string",
"description": "How the capability facts were produced. `exact` is a real platform probe with per-codec profiles/levels/bounds, `platform_attested` a decoder attestation without profile enumeration, `declared` a boolean support statement. Planner strictness follows the tier.",
"enum": [
"exact",
"platform_attested",
"declared"
],
"maxLength": 32
},
"hdr_capabilities": {
"type": "object",
"properties": {
"hdr10": {
"type": "boolean"
},
"hdr10_plus": {
"type": "boolean"
},
"hlg": {
"type": "boolean"
},
"hdr10_max_width": {
"type": "integer",
"minimum": 0
},
"hdr10_max_height": {
"type": "integer",
"minimum": 0
},
"hdr10_max_frame_rate": {
"type": "number",
"minimum": 0
},
"hdr10_max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
},
"dolby_vision_profile_levels": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"required": [
"profile",
"max_level"
],
"properties": {
"profile": {
"type": "integer",
"minimum": 1
},
"max_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"bl_compatibility_ids": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 15
}
}
},
"additionalProperties": true
}
}
},
"allOf": [
{
"if": {
"anyOf": [
{ "required": ["hdr10_max_width"] },
{ "required": ["hdr10_max_height"] },
{ "required": ["hdr10_max_frame_rate"] },
{ "required": ["hdr10_max_bitrate_kbps"] }
]
},
"then": {
"required": ["hdr10"],
"properties": { "hdr10": { "const": true } }
}
}
],
"additionalProperties": true
},
"audio_passthrough": {
"type": "object",
"properties": {
"passthrough_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"spatializer_enabled": {
"type": "boolean"
},
"max_channels": {
"type": "integer",
"minimum": 0,
"maximum": 64
},
"entries": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"properties": {
"codec": {
"type": "string",
"maxLength": 64
},
"channel_counts": {
"type": "array",
"maxItems": 32,
"items": {
"type": "integer"
}
},
"layouts": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string"
}
}
},
"additionalProperties": true
}
}
},
"additionalProperties": true
},
"video_decode_capability": {
"type": "object",
"required": [
"codec"
],
"properties": {
"codec": {
"type": "string",
"minLength": 1
},
"decoder_name": {
"type": "string",
"maxLength": 128
},
"profiles": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 64
}
},
"levels": {
"type": "array",
"maxItems": 64,
"items": {
"type": "integer"
}
},
"bit_depths": {
"type": "array",
"maxItems": 64,
"items": {
"type": "integer"
}
},
"max_width": {
"type": "integer",
"minimum": 0
},
"max_height": {
"type": "integer",
"minimum": 0
},
"max_frame_rate": {
"type": "number",
"minimum": 0
},
"max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"hardware": {
"type": "boolean"
}
},
"additionalProperties": true
},
"client_capabilities": {
"type": "object",
"required": [
"video_evidence",
"audio_evidence"
],
"properties": {
"video_evidence": {
"$ref": "#/$defs/capability_evidence"
},
"audio_evidence": {
"$ref": "#/$defs/capability_evidence"
},
"codecs_video": {
"$ref": "#/$defs/capability_value_list"
},
"codecs_video_hardware": {
"$ref": "#/$defs/capability_value_list"
},
"codecs_audio": {
"$ref": "#/$defs/capability_value_list"
},
"containers": {
"$ref": "#/$defs/capability_value_list"
},
"max_resolution": {
"type": "string",
"maxLength": 32
},
"hdr": {
"type": "boolean"
},
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"audio_passthrough": {
"$ref": "#/$defs/audio_passthrough"
},
"video_decode": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "#/$defs/video_decode_capability"
}
}
},
"additionalProperties": true
},
"transformation_capability": {
"type": "object",
"required": [
"name",
"executor",
"recipe_version"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"executor": {
"type": "string",
"enum": [
"client",
"server"
]
},
"recipe_version": {
"type": "string",
"minLength": 1,
"maxLength": 32
},
"validated_claims": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
}
}
},
"additionalProperties": true
},
"delivery_capability": {
"type": "object",
"description": "One negotiated delivery class the client can execute. A `client` executor transformation is only accepted on a delivery that is enabled and supported_on_device, and only when client_features advertises client_video_transformations_v1; (executor, name, recipe_version) must be unique within the delivery.",
"properties": {
"enabled": {
"type": "boolean"
},
"supported_on_device": {
"type": "boolean"
},
"failure_reason": {
"type": "string"
},
"containers": {
"$ref": "#/$defs/delivery_value_list"
},
"video_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"audio_decode_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"audio_passthrough_codecs": {
"$ref": "#/$defs/delivery_value_list"
},
"max_channels": {
"type": "integer"
},
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"subtitles": {
"type": "object",
"properties": {
"embedded_text": {
"type": "boolean"
},
"sidecar_text": {
"type": "boolean"
},
"ass_styling": {
"type": "boolean"
},
"embedded_bitmap": {
"type": "boolean"
},
"sidecar_bitmap": {
"type": "boolean"
},
"font_attachments": {
"type": "boolean"
}
},
"additionalProperties": true
},
"features": {
"$ref": "#/$defs/delivery_value_list"
},
"auth_header_refresh": {
"type": "boolean"
},
"validated_claims": {
"$ref": "#/$defs/delivery_value_list"
},
"transformations": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "#/$defs/transformation_capability"
}
}
},
"additionalProperties": true
},
"client_playback_context": {
"type": "object",
"required": [
"protocol_version"
],
"properties": {
"protocol_version": {
"type": "integer",
"const": 3
},
"form_factor": {
"type": "string",
"maxLength": 32
},
"app_version": {
"type": "string",
"maxLength": 64
},
"app_build": {
"type": "string",
"maxLength": 64
},
"app_channel": {
"type": "string",
"maxLength": 32
},
"device": {
"type": "object",
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
"properties": {
"platform": {
"type": "string",
"maxLength": 32
},
"os_version": {
"type": "string",
"maxLength": 128
},
"manufacturer": {
"type": "string",
"maxLength": 128
},
"model": {
"type": "string",
"maxLength": 128
},
"platform_details": {
"type": "object",
"maxProperties": 16,
"propertyNames": {
"minLength": 1,
"maxLength": 128
},
"additionalProperties": {
"type": "string",
"maxLength": 128
}
}
},
"additionalProperties": true
},
"output": {
"type": "object",
"properties": {
"hdr_details": {
"$ref": "#/$defs/hdr_capabilities"
},
"audio_passthrough": {
"$ref": "#/$defs/audio_passthrough"
},
"current_sink": {
"type": "string",
"maxLength": 128
},
"sink_type": {
"type": "string",
"maxLength": 128
},
"output_context_id": {
"type": "string",
"description": "Opaque token identifying the current output route. The server only ever compares it for equality, so any stable platform-native identity works and omitting it is valid.",
"maxLength": 128
}
},
"additionalProperties": true
},
"deliveries": {
"type": "object",
"description": "Keyed by delivery class: original_http, progressive, hls. A class the client omits is a class the planner will not route to.",
"maxProperties": 16,
"propertyNames": {
"maxLength": 64
},
"additionalProperties": {
"$ref": "#/$defs/delivery_capability"
}
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}
+8
View File
@@ -1,5 +1,13 @@
# Feature Changelog
## 2026-08-16
### Let viewers turn the intro prompt off
Skipping intros stops being a switch and becomes a choice of three: leave intros alone, offer a Skip Intro button, or skip automatically and offer an undo.
- Adds `playback.intro_skip_mode` (`never` / `ask` / `always`, default `ask`) at contract revision 7 and deprecates `playback.auto_skip_intro`, which could not express "never".
- Migrates every stored auto-skip-intro preference onto the new key, on both the PostgreSQL and per-user SQLite backends, so nobody's existing choice changes.
- Mirrors the two keys at write time for one release, so a preference set on an older phone, TV, or browser still shows up correctly on an updated one.
## 2026-04-09
Covers commits from 2026-04-08 22:32 EDT through 2026-04-09 20:02 EDT.
+68
View File
@@ -48,6 +48,38 @@ the other. Send the exact lower-case family:
| macOS | `desktop` |
| Browser | `web` |
### App identity headers
Separately from the family, every first-party client should send its own app
identity on playback requests. These are server-wide contextual headers, not
settings-specific: the playback session stores them, the admin Activity page
renders them ("Silo Android TV 1.0.0 (build 5)"), and playback decision logs
carry them so a report can be tied to an exact build.
| Header | Clamp | Meaning |
| ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------- |
| `X-Silo-Client` | 128 | Product name, e.g. `Silo Android TV`, `Silo iOS`. |
| `X-Silo-Client-Version` | 64 | Marketing version, e.g. `1.0.0`. Sent verbatim and displayed verbatim — do not pre-shorten it. |
| `X-Silo-Client-Build` | 64 | Opaque per-platform build identifier (Android `versionCode`, Apple `CFBundleVersion`). Never parsed or compared. |
| `X-Silo-Client-Channel` | 32 | Opaque distribution channel: `release`, `beta`, `sideload`, `dev`. Stored verbatim; `release` is not displayed. |
Values are trimmed and truncated to the clamp above — never rejected, on either
route, because an identity label must not be able to fail a playback start. The
clamp counts characters, not bytes, matching `maxLength` in the v3 request
schemas, and is applied where the request is read rather than where the session
is created so the decision logs and `playback_route_events` observe it too.
Nothing is validated against an enum either, so a client may introduce a new
channel without a server change.
Protocol-v3 `POST /playback/start` accepts `client_playback_context.app_version`,
`.app_build`, and `.app_channel` as a body-level fallback for clients that cannot
set the headers on every request. The headers win field by field when both are
present, and the fallback applies **only to a client that sent `X-Silo-Client`**:
`client_playback_context` carries no app name, so nothing in the body can
identify a client that did not name itself — such a session is labeled from its
user agent, and its `app_version` is a free-form platform string rather than the
marketing version `client_version` promises.
## Remote scopes
Every stored value has exactly one identity. Context fields not named by the
@@ -99,6 +131,42 @@ Content-Type: application/json
Stored-value responses include `client_family` when the source or explicit row
is at `profile_client`.
### Superseded keys and the write mirror
A definition may be marked `deprecated: true` in the manifest. It is still
served, still readable and still writable — old clients depend on it — but new
clients should read and write its replacement.
Revision 7 deprecates `playback.auto_skip_intro` in favor of
`playback.intro_skip_mode`, whose three members say what the boolean could not:
`never` (no prompt at all), `ask` (offer a Skip Intro button, what the boolean's
`false` always did) and `always` (skip it and offer an undo). The default is
`ask`, so an untouched profile behaves identically across the cutover.
For one release the server keeps the pair in step, so a preference set on any
client shows up correctly on the others:
| Request | Server also does |
| -------------------------------------- | ------------------------------------------------------------- |
| `PUT` of `playback.auto_skip_intro` | writes `playback.intro_skip_mode` at the same identity: `true → "always"`, `false → "ask"` |
| `PUT` of `playback.intro_skip_mode` | writes `playback.auto_skip_intro` at the same identity: `"always" → true`, otherwise `false` |
| `DELETE` of either | removes the other at the same identity |
| `PUT`/`POST /profiles` with `auto_skip_intro` | writes both keys at `profile` scope |
| `PUT`/`DELETE` of the legacy `/settings/{key}` or `/settings/device/{key}` for `playback.auto_skip_intro` | writes or clears both keys at the scope that route owns |
| `PUT` or `DELETE` of either key at `profile` scope | sets `user_profiles.auto_skip_intro` to what the pair now resolves to — the written value, or the contract default once the row is cleared — so the profile DTO stays truthful for clients that read it |
Both rows commit together on the idempotent write path, and a replayed
mutation id re-serves its receipt without writing either again. The response is
always the stored value of the key the request addressed; the companion row is
not reported. Only the addressed key raises a `user_settings.changed` event and
an audit record.
The boolean direction is lossy on purpose: a client that only understands the
switch sees `never` as `false` and shows the button. Such a client that then
flips the switch overwrites `never`, which is accepted for the overlap window
and is why the mirror is temporary. Once every client reads the enum, a
follow-up removes the mirror. Design: `docs/design/2026-08-16-intro-skip-mode.md`.
### Atomic navigation shortcuts
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
@@ -0,0 +1,366 @@
# Playback Protocol v3: Platform-Neutral Contract Finalization
**Status:** Proposed
**Scope:** `silo-server` (protocol owner), with coordinated breaking updates in
`silo-android`, `silo-apple`, and the in-repo web frontend (`web/src`)
**Supersedes:** the wire-contract sections (§3.3, §5.1–§5.2) of
`docs/superpowers/plans/2026-07-12-playback-protocol-v3.md`. The planner,
session, replan-idempotency, and transport architecture from that plan are
unchanged and remain authoritative.
Commands assume the repository root is the cwd.
## 1. Goal
Finalize the v3 playback protocol **in place** as a platform-neutral contract
before it carries production traffic. The protocol keeps its version number,
its endpoints, and its architecture (server-owned plans, deterministic plan
identity, idempotent replans, validated claims, route events). What changes is
the wire contract, which today is a serialization of Android Media3 concepts:
- the engine vocabulary is Media3 branding (`media3_direct`, `media3_hls`,
feature token `media3_only`, claims `media3_h264_decode`);
- loop-prevention attempt keys are client-computed FNV hashes whose canonical
string is defined by Kotlin enum constant names, forcing the Go server to
carry `KotlinName()` methods and Apple to reimplement the hash in Swift;
- the capability schema mirrors `android.media.MediaCodecInfo` and
`android.os.Build`, which Apple can only fill with hardcoded literals
(`profiles: []`, fabricated `output_route_generation`) and the web client
cannot fill at all;
- the normative spec lives in the Android repository.
**There is no backwards compatibility requirement.** v3 is dark
(`playback.protocol_v3_enabled` defaults to `false`), the only v3 speakers are
first-party test builds, and the product is early enough to require clients and
servers to update together. All three repos move in one coordinated release;
the legacy playback protocol is deleted, not deprecated.
## 2. Current state (verified)
| Surface | State |
| --- | --- |
| Server v3 | Complete behind `playback.protocol_v3_enabled`: capability, start, replan, route-events; planner in `internal/playback/plan_v3.go`; durable attempt store in `internal/playback/planstore/`. |
| Android | Speaks v3 natively; owns the normative Kotlin model and generates the attempt-key fixtures Go tests consume. |
| Apple | Speaks v3 through a ~1,050-line translation shim (`ProtocolV3/` directory) that aliases AVPlayer under Media3 engine names, hardcodes decode capabilities, and reimplements the Kotlin attempt-key hash. |
| Web video player | Legacy protocol only: flat codec lists to `POST /playback/start`, client-chosen FFmpeg recipes via `POST /playback/transcode/start`, audio switch via `PATCH /playback/{id}/audio`. |
| Web audiobook player | Legacy `POST /playback/start` with `play_method: "direct"`. |
| Transcode nodes | Advertise the shared transformation registry (`internal/playback/transformations_v3.go`), including its `media3_*` validated-claim strings. |
| jellycompat | Separate protocol surface for Jellyfin clients; out of scope. |
## 3. Contract changes
### 3.1 Server-owned attempt keys
The server already computes `PlanAttemptKeyV3` to enforce loop prevention.
Make that the only implementation:
- Every `PlanV3` response carries `plan_attempt_key`, an **opaque token**.
Clients store the keys of attempted plans and echo them in
`attempted_plan_keys` on replan. Clients never hash anything.
- A client that applies a local mutation (Android's PCM recovery route) reports
it as a bounded string list `local_mutations` on the replan; the server folds
it into the key.
- Delete `KotlinName()` on `DeliveryV3`, `StreamProtocolV3`, and
`SubtitleModeV3` (`internal/playback/protocol_v3.go`). The canonical string
in `internal/playback/plan_key_v3.go` switches to lowercase wire tokens. The
key remains FNV-1a `v3:<16-hex>` so existing store columns and log tooling
keep working, but the algorithm is now a server implementation detail.
- Bump `PlanRecipeVersionV3` (currently `v3.2`) so no key or plan ID computed
under the old canonicalization can collide with the new scheme.
- Golden fixtures in `internal/playback/testdata/protocol_v3/` are regenerated
**by Go** and become the cross-repo source of truth (§3.7). The
"fixtures must be generated by the checked-in Kotlin implementation" rule
from the original plan is retired along with the reason it existed.
Deterministic `plan_id` (`DeterministicPlanIDV3`) stays: the server still needs
recipe-stable identity for idempotency and telemetry. Only the client-visible
hashing contract disappears.
### 3.2 Engines leave the wire; deliveries are the negotiation unit
Delete `EngineV3` from the wire contract. The delivery→engine mapping was
already fixed one-to-one, so `delivery` plus `stream.protocol` carries all the
information; each client maps deliveries to its own player internally
(Media3, AVPlayer, hls.js).
- `ClientPlaybackContextV3.Engines` (map keyed `media3_*`) becomes
`deliveries`, keyed by the delivery class the client can execute:
`original_http`, `progressive`, `hls`. The value keeps the existing
per-engine shape (containers, codecs, subtitle capabilities, features,
transformations), renamed `DeliveryCapabilityV3`.
- `PlanV3.Engine` is removed. `ResolveSubtitlePolicyV3` and
`engineAvailableV3` re-key on delivery class; their logic is unchanged.
- Feature token `media3_only` is deleted from `ServerFeaturesV3()`.
- Validated-claim strings drop the prefix: `media3_h264_decode` →
`h264_decode`, `media3_audio_decode` → `audio_decode` (registry specs in
`internal/playback/transformations_v3.go`; flows automatically to the
transcode-node advertisement, which matches on name/version/executor and is
version-locked to the server binary anyway).
- Reserved legacy Android enum values (`mpv_direct`, `client_local_loopback`,
`external_player`, `client_local_normalization`) lose their protocol
reservation; they become Android-internal history.
### 3.3 Tiered capability evidence
Replace the implicit "Android fills everything, others fake it" model with an
explicit per-area evidence tier, and make planner strictness follow the tier:
```text
video_evidence: "exact" | "platform_attested" | "declared"
audio_evidence: "exact" | "platform_attested" | "declared"
```
- **exact** — per-codec profiles/levels/bit-depths/bounds from a real platform
probe (Android `MediaCodecList`). Full strict validation, exactly today's
`detailedVideoEligibleV3` path.
- **platform_attested** — platform-level decoder attestation without
profile/level enumeration (Apple/VideoToolbox): codec, hardware flag, max
resolution/frame-rate, bit depths, HDR ranges. The planner validates codec,
resolution, bit depth, frame rate, and dynamic range, and skips
profile/level matching instead of failing conservative. Apple stops
advertising `detailed_decode_capabilities` facts it cannot produce.
- **declared** — boolean support statements (web `MediaSource.isTypeSupported`).
The request is valid (today web cannot construct a legal v3 request at all);
the planner grants copy routes on codec+container+range match and otherwise
adapts. No strict direct claims.
The `detailed_decode_capabilities` feature token is subsumed by
`video_evidence: "exact"` and removed. Audio passthrough entries stay optional:
only `exact` audio evidence earns passthrough claims; `platform_attested`
decode claims suffice for copy routes, which unblocks Apple's current
never-passthrough outcome honestly (Apple simply cannot attest sink layouts —
the tier records that instead of a stubbed-empty struct pretending to).
Terminal reasons must distinguish "your evidence tier cannot support this
route" (`evidence_insufficient_for_direct`) from "your device cannot play
this" so `declared`-tier clients get actionable degradation warnings rather
than mystery transcodes.
### 3.4 Neutral device and output context
- `DeviceContextV3` (an `android.os.Build` dump: `sdk_int`, `abis`,
`security_patch`, `build_display`, …) is replaced by:
```text
platform e.g. "android", "ios", "tvos", "macos", "web"
os_version bounded string
manufacturer kept first-class: the quirk registry matches on it
model kept first-class: the quirk registry matches on it
form_factor existing field, unchanged
platform_details bounded map<string,string> (≤16 entries, ≤128 chars each)
```
Android moves `sdk_int`, `soc_model`, build fields into `platform_details`;
the Fire TV quirks in `internal/playback/device_quirks_v3.go` keep working on
`manufacturer`/`model` unchanged. The quirk mechanism (namespaced IDs,
registry revision, applied-quirk reporting) already generalizes to `apple.*`
and `web.*` quirks; nothing to change there.
- `output_route_generation` (int64, strictly cross-validated in two request
locations, modeling Android's audio-route callback counter) becomes
`output_context_id`: an optional bounded opaque string. The server only ever
uses it to distinguish "same output route" from "route changed" in attempt
keys and plan invalidation, which an opaque token does on every platform.
Android supplies its route generation stringified; Apple supplies its
existing synthetic sink hash (no longer masked into an int64); web omits it.
The dual-location consistency validation is deleted.
- Dual-location feature advertisement (`client_features` at top level *and*
`client_playback_context.features`) collapses to the top-level list only.
`HasFeatureV3` call sites lose their second lookup.
### 3.5 Replan gains track and quality operations; legacy endpoints die
Extend `ReplanOperationV3` beyond failure recovery and seek:
- `track_change` — new `selected_tracks`, no failure classification required.
Replaces `PATCH /playback/{session_id}/audio`.
- `quality_change` — new `quality_preference`, no failure classification.
Replaces the client-recipe half of `POST /playback/transcode/start`.
Both run through the existing replan transaction (validation → capacity
reservation → staged successor → atomic commit), so they inherit idempotency,
loop prevention, and restart safety for free.
To let clients render a quality menu without owning a bitrate table,
`PlanV3` gains `available_qualities`: the server ladder rungs valid for this
source and client (label, height, approximate bitrate, plus `original`).
The web ladder in `web/src/player/hooks/useTranscodeQuality.ts` is deleted,
not reconciled — resolving the original plan's §6.3 "one ladder" requirement
in the server's favor.
### 3.6 Audio-only sources join the planner
The v3 planner currently terminals on sources without complete video metadata,
which is why the audiobook player must stay on the legacy start path. Teach
`PlanPlaybackV3` audio-only sources: when the effective file has no video
track, skip video/HDR/subtitle-burn gates and plan `original_http` (validated
audio decode) or an audio transformation route. This is what allows the legacy
start path to be deleted entirely rather than kept alive for audiobooks, and
it is the natural extension point for future music support.
### 3.7 Spec and fixtures move into this repository
- New normative spec: `docs/architecture/playback-protocol-v3.md` — endpoint
contract, request/response schemas, evidence tiers, delivery semantics,
timeline model, replan state machine, terminal reason registry, and the
frozen track-identity ordering rules.
- JSON Schemas for every wire body under `docs/design/schemas/playback-v3/`,
following the existing `docs/design/schemas/client-diagnostics` convention.
- Golden fixtures (`internal/playback/testdata/protocol_v3/`) are generated by
a Go generator and consumed by Android and Apple CI as opaque expected
values. The direction of authority is inverted: the server defines the
contract, clients prove conformance.
- Track identity stays ordinal-based (`file:{id}:{kind}:{ordinal}`) — churn is
not worth it — but the combined-ordinal ordering rules are specified here
instead of by reference to Android's `buildSubtitleURLs`. The known
`SubtitleTrackMerge` undercount (non-PGS burn-in tracks omitted from
`subtitle_urls` but still counted in the downloaded-track offset) is fixed
server-side as part of this pass; the Apple-side "do NOT fix" annotation is
then removed.
### 3.8 Legacy playback protocol deletion
With web (video + audiobook) on v3, delete:
- `handleStartPlaybackLegacy` and `startPlaybackRequest` /
`playbackSessionResponse` (`internal/api/handlers/playback.go`); the
protocol-version dispatch envelope in `HandleStartPlayback` is replaced by a
strict v3 decode. A start without `protocol_version: 3` gets
`426 upgrade_required` with code `client_upgrade_required` so outdated apps
render a clear "update required" state.
- `POST /playback/transcode/start` (`HandleStartTranscode`) and
`PATCH /playback/{session_id}/audio` (`HandleChangeAudioTrack`).
- The shadow planner (`internal/api/handlers/playback_v3_shadow.go`) and
`playback.protocol_v3_shadow_enabled` — its purpose (compare v3 against
legacy production routes) ends when legacy routes end.
- `playback.protocol_v3_enabled` — once v3 is the only protocol, "disabled"
would mean "no playback". Removing both settings rows is a new Goose
migration (`make migrate-create NAME=remove_playback_protocol_v3_flags`).
- Legacy `resolver.go` paths that only legacy start reached (verify with
coverage before deleting; jellycompat has its own resolution surface and
must be untouched).
Per the v1 API rules, every removed endpoint and the changed start-body
requirement are recorded in the pre-lock removals table in
`docs/architecture/v1-scope.md` before merge. `/api/v1` is not locked, so this
is in scope; it stops being possible after lock.
Unchanged surfaces: progress, stop, HLS manifest/segment delivery, the
realtime control socket, stream tokens and restart reconstruction, watch
together, downloads, jellycompat.
## 4. Client repository work
**silo-android** — mostly deletion. Remove the attempt-key implementation and
its fixtures-as-source-of-truth role, the engine self-description under
`media3_*` keys (becomes `deliveries`), and the `Build` dump (moves to
`platform_details`). Adopt `plan_attempt_key` echo, `output_context_id`,
`video_evidence: "exact"`, and the two new replan operations (its quality/track
UI currently routes through replan-with-failure or legacy paths). Consume Go
fixtures in CI.
**silo-apple** — the shim shrinks to near nothing. Delete the Swift FNV hash,
the Media3 alias table, and the hardcoded decode-capability fabrication;
advertise `video_evidence: "platform_attested"` with honest VideoToolbox
attestation, and `audio_evidence: "platform_attested"` (no passthrough claims).
Keep the plan validator (`validatedForApple()`) — defensive validation of
server plans is good practice, not translation tax. Remove the subtitle
undercount workaround once the server fix lands.
**web (this repo)** — the real migration. Video player: replace the legacy
start payload with a v3 request (`video_evidence: "declared"` from
`isTypeSupported` probes, `deliveries: {original_http, progressive, hls}`),
consume `PlanV3` (URL, timeline, tracks, warnings), switch quality/track
changes to replan, delete `useTranscodeQuality`'s ladder and recipe posting,
and implement `route-events` for at least plan-failed/terminal so web failures
become visible in the same telemetry as the apps. Audiobook player: v3 start
against the audio-only planner path (§3.6). `WEBTEST_KNOWN_FAILURES` may only
shrink during this work.
## 5. Rollout
One coordinated release train; no compatibility window.
1. Land the server contract changes with v3 still dark. Fixtures and JSON
Schemas merge first so client work can start against them.
2. Land Android and Apple ports; both CI suites consume the Go fixtures.
3. Land the web migration (video, then audiobooks) in this repo.
4. On the release: server advertises the finalized contract via
`GET /playback/capability`; legacy start returns `426`. App-store builds and
the server release ship together; older apps show the update-required state.
5. After the release train merges and soaks on a validation deployment, the
contract is **frozen**: all future changes go through the additive feature
tokens and capability endpoint that v3 already has. This pass is the last
breaking window by design.
Rollback within the window is "fix forward or revert the train": with the
legacy protocol deleted there is deliberately no protocol fallback. That is the
accepted cost of the no-compatibility decision, and it is why step 4 gates on
full fixture parity in all three repos, not on manual testing alone.
## 6. Testing
- Regenerate all planner/contract fixtures from the Go generator; add
fixtures for: evidence-tier gating (exact vs platform_attested vs declared
reaching different routes for the same source), `deliveries` negotiation,
opaque attempt-key echo and loop rejection, `track_change`/`quality_change`
replans (idempotent duplicates, concurrent, mid-seek), `available_qualities`
correctness against the ladder, audio-only planning, `output_context_id`
change invalidation, and `426` on legacy start bodies.
- Keep the full existing v3 matrix from the original plan §10 (HDR/DV, audio,
subtitles, recovery, restart, capacity) — the planner logic it exercises is
unchanged; fixtures re-key from engine to delivery.
- Fire TV quirk tests unchanged (manufacturer/model matching survives §3.4).
- Web: player integration tests against a real backend via the
`web-ui-testing` flow; jellycompat and audiobook suites must stay green.
Verification before each PR in the train:
```text
make lint
make test
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
```
## 7. Sequencing within silo-server
Each step is one reviewable PR (`Part of` the playback-protocol epic):
1. **Spec + schemas + fixture generator** — docs, JSON Schemas, Go generator,
no behavior change.
2. **Attempt-key ownership** — `plan_attempt_key` in responses, opaque echo,
delete `KotlinName()`, re-canonicalize, bump `PlanRecipeVersionV3`.
3. **Engine removal + claim rename** — wire types, planner re-key, registry
claim strings, node-advertisement test update.
4. **Evidence tiers + neutral device/output context** — request schema,
validation, planner gating, terminal reasons.
5. **Replan operations + `available_qualities`** — planner and handler.
6. **Audio-only planning.**
7. **Web migration** (video, then audiobooks).
8. **Legacy deletion + settings-flag removal migration + v1-scope table
entries + subtitle-ordinal fix.**
Android/Apple ports proceed in parallel from step 1's artifacts; step 8 merges
only after both client ports are green against the fixtures.
## 8. Definition of done
- No Media3, Kotlin, or `android.os.Build` identifier appears in the wire
contract, the attempt-key canonical string, or the normative docs.
- Clients treat `plan_attempt_key` as opaque; exactly one attempt-key
implementation exists, in Go.
- The same source file yields correct, tier-appropriate routes for an `exact`
Android request, a `platform_attested` Apple request, and a `declared` web
request in fixture tests.
- Web video and audiobook playback run on v3; `useTranscodeQuality`'s ladder
and recipe posting are deleted.
- Legacy start, `transcode/start`, and the audio PATCH are removed and
recorded in the v1-scope pre-lock removals table.
- `docs/architecture/playback-protocol-v3.md` is sufficient for a third-party
client to implement playback without reading any client repository.
- Android and Apple CI consume the Go fixtures; the Apple shim's alias table
and Swift hash are gone.
- Existing jellycompat, watch-together, downloads, and restart-resilience
tests remain green.
@@ -235,7 +235,9 @@ DROP TABLE IF EXISTS public.download_artifacts;
`params_hash = sha256(format | container | codec_video | codec_audio | resolution | audio_track_index | subtitle_burn_in)`.
Two devices (or a web user and a phone) requesting the same 1080p H.264/AAC of the same source share one
artifact. `last_used_at` drives LRU cleanup; `output_path` is on a configured artifact volume. The
artifact. `last_used_at` drives LRU cleanup. `output_path` remains the deterministic API-local
fallback target; distributed preparation additionally stores an opaque artifact id plus its
transcode-node origin and group, allowing an authenticated proxy relay without a shared filesystem. The
`attempts`/`lease_*`/`next_retry_at` columns make the table a **durable, recoverable job queue** (see the
"Durable artifact queue" subsection) so a crash mid-encode cannot strand a download in `preparing`.
@@ -244,7 +246,8 @@ artifact. `last_used_at` drives LRU cleanup; `output_path` is on a configured ar
`server_settings` is key/value, so new keys need no migration — written when an admin saves them, parsed
in `internal/config/db_loader.go`:
- `download.transcode_enabled` (bool, default **false**) — server gate for transcode-to-file.
- `download.artifact_dir` (string) — artifact output volume; defaults under the existing transcode/temp root.
- `download.artifact_dir` (string) — local artifact output volume; dedicated transcode nodes default
to a protected directory inside their existing transcode volume.
- `download.max_concurrent_prepares` (int, default 2) — encode/remux worker-pool size.
- `download.artifact_max_bytes` (int64, default 0 = unlimited) — LRU eviction budget for artifacts.
@@ -289,9 +292,9 @@ Decision tree:
`ErrDownloadNotAllowed` (403). Missing artifact pipeline → `ErrQualityUnavailable` (501).
4. `remux` and `transcode` as request values are invalid public qualities.
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing `playback.Resolve` /
`playback.SelectVersion` (`internal/playback/resolver.go:60`) against the device's declared capabilities
and the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing
`playback.Resolve` (`internal/playback/resolver.go`) against the device's declared capabilities and
the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
duplicated codec logic).
## Transcode-to-file pipeline (v1)
@@ -1,5 +1,13 @@
# Transcode Resolution Clamp Design
**Status: superseded — the invariant survives, the implementation described here
does not.** `POST /api/v1/playback/transcode/start` and the handler-side helper
were removed with the legacy playback protocol
([spec](../../architecture/playback-protocol-v3.md),
[plan](../plans/2026-07-30-playback-protocol-v3-neutral-contract.md)). The
decision below still holds: read it for *why* the server clamps, and see
"Where it lives now" for *where*.
## Problem
When video encoding is requested for a 2160p file while 4K transcoding is
@@ -34,14 +42,29 @@ The normalization rules are:
Recognized resolutions are the same tiers already supported by the transcode
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
## Architecture and Data Flow
## Where it lives now
A small pure helper in `internal/api/handlers/playback.go` compares requested
and effective-source resolution tiers. `HandleStartTranscode` calls it once
after the 4K alternate guard has selected and probed the effective file.
Under protocol v3 the client no longer names an output resolution, so there is
no requested tier to clamp at the API boundary. The invariant moved into the
planner, where it is enforced twice against the probed source:
The normalized value replaces `req.TargetResolution`. Existing downstream
paths then automatically use one consistent value for:
- `availableQualitiesV3` (`internal/playback/plan_v3.go`) omits every ladder rung
at or above the source height, so an over-source target is never offered.
- The encode-target path clamps `targetHeight` to `source.Height` before building
the recipe, so a stale or hand-built `quality_change` cannot reintroduce one.
Because the plan is the only way to reach FFmpeg, one clamp now covers session
state, activity reporting, and restart reconstruction — the paths the original
design had to reach through `req.TargetResolution`.
## Architecture and Data Flow (as originally shipped)
A small pure helper in `internal/api/handlers/playback.go` compared requested
and effective-source resolution tiers. `HandleStartTranscode` called it once
after the 4K alternate guard had selected and probed the effective file.
The normalized value replaced `req.TargetResolution`. Existing downstream
paths then automatically used one consistent value for:
- transcode-node planning and requests;
- integrated/local `playback.TranscodeOpts`;
+9
View File
@@ -278,6 +278,15 @@ func (s stubStore) GetSeriesPlaybackPreference(context.Context, string, string)
func (s stubStore) DeleteSeriesPlaybackPreference(context.Context, string, string) error {
panic("unused")
}
func (s stubStore) SetCollectionSortPreference(context.Context, userstore.CollectionSortPreference) error {
panic("unused")
}
func (s stubStore) GetCollectionSortPreference(context.Context, string, string, string) (*userstore.CollectionSortPreference, error) {
panic("unused")
}
func (s stubStore) ClearCollectionSortPreference(context.Context, string, string, string) error {
panic("unused")
}
func (s stubStore) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
panic("unused")
}
+3
View File
@@ -9,6 +9,7 @@ import (
)
func requestAccessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
@@ -17,10 +18,12 @@ func requestAccessFilter(r *http.Request) catalog.AccessFilter {
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
return catalog.AccessFilter{
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -0,0 +1,18 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestCatalogAccessFiltersCarryExplicitDeviceIdentity(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/items/movie-1", nil)
req.Header.Set(deviceIDHeader, " apple-tv ")
if got := requestAccessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("requestAccessFilter().DeviceID = %q, want apple-tv", got)
}
if got := (&ItemsHandler{}).accessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("ItemsHandler.accessFilter().DeviceID = %q, want apple-tv", got)
}
}
+16
View File
@@ -59,6 +59,16 @@ type catalogResponse struct {
Items []itemListResponse `json:"items"`
Snapshot string `json:"snapshot,omitempty"`
SearchDiagnostics *searchDiagnostics `json:"search_diagnostics,omitempty"`
// EffectiveSort reports the order a collection source actually resolved in
// once the viewer's saved override and the collection's configured default
// were applied. Omitted for non-collection sources and when the collection
// resolved in its own source order.
EffectiveSort *effectiveSortResponse `json:"effective_sort,omitempty"`
}
type effectiveSortResponse struct {
Field string `json:"field"`
Order string `json:"order"`
}
// searchDiagnostics is an additive, per-query observability object emitted on
@@ -244,6 +254,11 @@ func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *cat
}
}
var effectiveSort *effectiveSortResponse
if field := strings.TrimSpace(result.EffectiveSort.Field); field != "" {
effectiveSort = &effectiveSortResponse{Field: field, Order: result.EffectiveSort.Order}
}
writeJSON(w, http.StatusOK, catalogResponse{
Total: result.Total,
TotalExact: result.TotalExact && !groupedByWork,
@@ -251,6 +266,7 @@ func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *cat
Items: items,
Snapshot: snapshot,
SearchDiagnostics: diag,
EffectiveSort: effectiveSort,
})
}
@@ -0,0 +1,195 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
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/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type collectionPreferenceLibraryReader interface {
GetByID(ctx context.Context, id string) (*models.LibraryCollection, error)
}
// collectionSortPreferenceRequest saves the sort a viewer chose while browsing a
// collection. An empty Field is meaningful — it pins the viewer to the
// collection's own source order even if its creator later configures a default.
// To go back to following the creator's default, DELETE the preference instead.
type collectionSortPreferenceRequest struct {
CollectionKind string `json:"collection_kind"`
CollectionID string `json:"collection_id"`
Field string `json:"field"`
Order string `json:"order"`
}
type collectionSortPreferenceResponse struct {
CollectionKind string `json:"collection_kind"`
CollectionID string `json:"collection_id"`
Field string `json:"field"`
Order string `json:"order"`
}
// NormalizeCollectionSortConfig validates the default sort a collection's
// creator configured and returns its canonical sort_config JSON. Shared by the
// personal and library collection handlers so both id spaces accept exactly the
// same vocabulary.
//
// allowPersonalized must be false for library collection defaults because
// those defaults are shared across profiles. Viewer overrides are validated
// separately and may use profile-scoped fields for either collection kind.
func NormalizeCollectionSortConfig(raw json.RawMessage, allowPersonalized bool) (string, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == jsonNullLiteral || trimmed == "{}" {
return "{}", nil
}
var cfg struct {
Field string `json:"field"`
Order string `json:"order"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", errInvalidSortConfig
}
if strings.TrimSpace(cfg.Field) == "" {
// sort_config predates default sorting and may contain independent
// collection modes such as {"mode":"manual_pins"}. Preserve those
// configurations byte-for-byte instead of erasing them merely because
// they do not contain a sort field.
return string(raw), nil
}
qs, ok := catalog.NormalizeCollectionSort(cfg.Field, cfg.Order, allowPersonalized)
if !ok {
return "", errInvalidSortConfig
}
return catalog.EncodeCollectionDefaultSort(qs, true)
}
var errInvalidSortConfig = &sortConfigError{}
type sortConfigError struct{}
func (*sortConfigError) Error() string {
return "sort_config must be {} or {\"field\":\"<supported sort>\",\"order\":\"asc|desc\"}"
}
// HandleSetCollectionSortPreference handles PUT /collections/sort-preference.
func (h *CollectionHandler) HandleSetCollectionSortPreference(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req collectionSortPreferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
kind, collectionID, ok := normalizeCollectionRef(req.CollectionKind, req.CollectionID)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "collection_kind must be 'library' or 'user' and collection_id is required")
return
}
field := strings.ToLower(strings.TrimSpace(req.Field))
order := ""
if field != "" {
qs, valid := catalog.NormalizeCollectionSort(field, req.Order, true)
if !valid {
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported sort field or order for this collection")
return
}
field, order = qs.Field, qs.Order
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if !h.canSaveCollectionSortPreference(r, store, kind, collectionID) {
// Use one not-found response for absent and inaccessible collections so
// the preference endpoint cannot be used to enumerate hidden IDs.
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
if err := store.SetCollectionSortPreference(r.Context(), userstore.CollectionSortPreference{
ProfileID: profileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: field,
SortOrder: order,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save sort preference")
return
}
writeJSON(w, http.StatusOK, collectionSortPreferenceResponse{
CollectionKind: kind,
CollectionID: collectionID,
Field: field,
Order: order,
})
}
func (h *CollectionHandler) canSaveCollectionSortPreference(
r *http.Request,
store userstore.UserStore,
kind, collectionID string,
) bool {
switch kind {
case userstore.CollectionKindLibrary:
if h.LibraryCollections == nil {
return false
}
collection, err := h.LibraryCollections.GetByID(r.Context(), collectionID)
return err == nil && catalog.CanAccessLibraryCollection(collection, requestAccessFilter(r))
case userstore.CollectionKindUser:
collection, err := store.GetCollection(r.Context(), collectionID)
return err == nil && catalog.ProfileCanAccessCollection(collection, apimw.GetProfileID(r.Context()))
default:
return false
}
}
// HandleClearCollectionSortPreference handles DELETE /collections/sort-preference,
// returning the collection to whatever default its creator configured.
func (h *CollectionHandler) HandleClearCollectionSortPreference(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
values := r.URL.Query()
kind, collectionID, ok := normalizeCollectionRef(values.Get("collection_kind"), values.Get("collection_id"))
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "collection_kind must be 'library' or 'user' and collection_id is required")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if err := store.ClearCollectionSortPreference(r.Context(), profileID, kind, collectionID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear sort preference")
return
}
w.WriteHeader(http.StatusNoContent)
}
func normalizeCollectionRef(rawKind, rawID string) (string, string, bool) {
kind := strings.ToLower(strings.TrimSpace(rawKind))
if kind != userstore.CollectionKindLibrary && kind != userstore.CollectionKindUser {
return "", "", false
}
collectionID := strings.TrimSpace(rawID)
if collectionID == "" {
return "", "", false
}
return kind, collectionID, true
}
@@ -0,0 +1,302 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/userstore/pgstore"
)
type sortPrefLibraryReader struct {
collection *models.LibraryCollection
}
func (r sortPrefLibraryReader) GetByID(_ context.Context, id string) (*models.LibraryCollection, error) {
if r.collection == nil || r.collection.ID != id {
return nil, catalog.ErrLibraryCollectionNotFound
}
return r.collection, nil
}
// These exercise the sort-preference endpoints through real HTTP handlers and a
// real store, using middleware.SetClaims / SetProfileID to supply the auth
// context the middleware would normally attach. Set SILO_TEST_DATABASE_URL to a
// migrated database to run them.
func sortPrefHandlerPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(context.Background(),
`SELECT to_regclass('public.user_collection_sort_preferences')::text`).Scan(&tableName); err != nil {
t.Fatalf("check preference table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the collection sort preference migration")
}
return pool
}
func sortPrefFixtureUser(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var userID int
if err := pool.QueryRow(context.Background(), `
INSERT INTO users (username, email, password_hash, role)
VALUES ('sort-pref-handler-fixture', 'sort-pref-handler@invalid.test', '', 'user')
RETURNING id
`).Scan(&userID); err != nil {
t.Fatalf("seed fixture user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
return userID
}
// authed attaches the user + profile scope the middleware chain would normally
// have put on the request.
func authed(r *http.Request, userID int, profileID string) *http.Request {
ctx := middleware.SetClaims(r.Context(), &auth.Claims{UserID: userID, Role: "user"})
ctx = middleware.SetProfileID(ctx, profileID)
return r.WithContext(ctx)
}
func TestCollectionSortPreferenceEndpoints(t *testing.T) {
pool := sortPrefHandlerPool(t)
userID := sortPrefFixtureUser(t, pool)
const profileID = "profile-http"
const collectionID = "http-collection-1"
provider := pgstore.NewPostgresProvider(pool)
handler := NewCollectionHandler(provider)
handler.LibraryCollections = sortPrefLibraryReader{collection: &models.LibraryCollection{
ID: collectionID, Visibility: "visible", LibraryIDs: []int{7},
}}
store, err := provider.ForUser(context.Background(), userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: profileID, Name: "HTTP sort preference"}); err != nil {
t.Fatalf("seed profile: %v", err)
}
personalCollection, err := store.CreateCollection(context.Background(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: "HTTP personal sort preference",
CollectionType: "manual",
})
if err != nil {
t.Fatalf("seed personal collection: %v", err)
}
t.Cleanup(func() {
_ = store.DeleteProfile(context.Background(), profileID)
})
put := func(body string) *httptest.ResponseRecorder {
req := authed(httptest.NewRequest(http.MethodPut, "/collections/sort-preference", strings.NewReader(body)), userID, profileID)
rec := httptest.NewRecorder()
handler.HandleSetCollectionSortPreference(rec, req)
return rec
}
t.Run("saves a sort the viewer picked", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"title","order":"asc"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp collectionSortPreferenceResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Field != "title" || resp.Order != "asc" {
t.Fatalf("response = %+v", resp)
}
// Read it back through the store, not the response, so the assertion
// covers persistence rather than echo.
pref, err := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if err != nil || pref == nil {
t.Fatalf("stored preference = %v, err = %v", pref, err)
}
if pref.SortField != "title" || pref.SortOrder != "asc" {
t.Fatalf("stored preference = %+v", pref)
}
})
t.Run("fills in the field's default order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"year"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil || pref.SortOrder != "desc" {
t.Fatalf("stored preference = %+v, want year/desc", pref)
}
})
t.Run("an empty field pins to source order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":""}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil {
t.Fatal("pinning to source order removed the row; it must persist as an explicit choice")
}
if pref.SortField != "" {
t.Fatalf("stored field = %q, want empty", pref.SortField)
}
})
t.Run("accepts a personalized viewer sort on a library collection", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"progress"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, _ := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if pref == nil || pref.SortField != "progress" || pref.SortOrder != "desc" {
t.Fatalf("stored preference = %+v, want progress/desc", pref)
}
})
t.Run("rejects an invalid sort order", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"` + collectionID + `","field":"title","order":"sideways"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("accepts a personalized sort on a personal collection", func(t *testing.T) {
rec := put(`{"collection_kind":"user","collection_id":"` + personalCollection.ID + `","field":"progress"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindUser, personalCollection.ID)
})
})
t.Run("rejects a nonexistent collection", func(t *testing.T) {
rec := put(`{"collection_kind":"library","collection_id":"missing","field":"title"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("rejects a library collection outside the viewer scope", func(t *testing.T) {
req := authed(httptest.NewRequest(http.MethodPut, "/collections/sort-preference",
strings.NewReader(`{"collection_kind":"library","collection_id":"`+collectionID+`","field":"title"}`)), userID, profileID)
req = req.WithContext(access.SetScope(req.Context(), access.Scope{AllowedLibraryIDs: []int{99}}))
rec := httptest.NewRecorder()
handler.HandleSetCollectionSortPreference(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", rec.Code, rec.Body.String())
}
})
t.Run("rejects an unknown collection kind", func(t *testing.T) {
rec := put(`{"collection_kind":"nonsense","collection_id":"x","field":"title"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
})
t.Run("clearing removes the override", func(t *testing.T) {
req := authed(httptest.NewRequest(http.MethodDelete,
"/collections/sort-preference?collection_kind=library&collection_id="+collectionID, nil), userID, profileID)
rec := httptest.NewRecorder()
handler.HandleClearCollectionSortPreference(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
pref, err := store.GetCollectionSortPreference(context.Background(), profileID, userstore.CollectionKindLibrary, collectionID)
if err != nil {
t.Fatalf("reading cleared preference: %v", err)
}
if pref != nil {
t.Fatalf("preference still present after clear: %+v", pref)
}
})
}
// TestNormalizeCollectionSortConfigForCreators covers the validation the
// collection create/update handlers apply to a creator-configured default.
func TestNormalizeCollectionSortConfigForCreators(t *testing.T) {
cases := []struct {
name string
raw string
allowPersonalized bool
want string
wantErr bool
}{
{name: "absent", raw: "", want: "{}"},
{name: "explicit null", raw: `null`, want: "{}"},
{name: "legacy empty object", raw: `{}`, want: "{}"},
{name: "empty field remains source order", raw: `{"field":""}`, want: `{"field":""}`},
{name: "preserves manual pins mode", raw: `{"mode":"manual_pins"}`, want: `{"mode":"manual_pins"}`},
{name: "canonicalizes", raw: `{"field":" Title ","order":"ASC"}`, want: `{"field":"title","order":"asc"}`},
{name: "fills the default order", raw: `{"field":"year"}`, want: `{"field":"year","order":"desc"}`},
{name: "rejects unknown fields", raw: `{"field":"not_a_sort"}`, wantErr: true},
{name: "rejects invalid orders", raw: `{"field":"year","order":"sideways"}`, wantErr: true},
{name: "rejects personalized for library", raw: `{"field":"progress"}`, wantErr: true},
{
name: "allows personalized for personal",
raw: `{"field":"progress"}`,
allowPersonalized: true,
want: `{"field":"progress","order":"desc"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := NormalizeCollectionSortConfig(json.RawMessage(tc.raw), tc.allowPersonalized)
if tc.wantErr {
if err == nil {
t.Fatalf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
})
}
}
func TestNormalizeCollectionSortConfigPreservesManualPinsAcrossUpdates(t *testing.T) {
const manualPins = `{"mode":"manual_pins"}`
created, err := NormalizeCollectionSortConfig(json.RawMessage(manualPins), false)
if err != nil {
t.Fatalf("normalize on create: %v", err)
}
updated, err := NormalizeCollectionSortConfig(json.RawMessage(created), false)
if err != nil {
t.Fatalf("normalize on update: %v", err)
}
if updated != manualPins {
t.Fatalf("manual pins config changed across create/read/update: got %s, want %s", updated, manualPins)
}
}
+24 -9
View File
@@ -22,11 +22,12 @@ import (
// CollectionHandler handles personal collection CRUD endpoints.
type CollectionHandler struct {
storeProvider userstore.UserStoreProvider
Executor *catalog.QueryExecutor
S3GP *s3client.Client
HTTPClient *http.Client
PresignTTL time.Duration
storeProvider userstore.UserStoreProvider
LibraryCollections collectionPreferenceLibraryReader
Executor *catalog.QueryExecutor
S3GP *s3client.Client
HTTPClient *http.Client
PresignTTL time.Duration
}
// NewCollectionHandler creates a new CollectionHandler.
@@ -106,8 +107,11 @@ type collectionCapabilitiesResponse struct {
// DisplayFilterFields are the catalog query fields a personal-collection
// display filter may use. Clients build a display_query_definition fragment
// from these rather than a bespoke enum.
DisplayFilterFields []string `json:"display_filter_fields"`
DisplayFilterPresets collectionDisplayFilterPresets `json:"display_filter_presets"`
DisplayFilterFields []string `json:"display_filter_fields"`
DisplayFilterPresets collectionDisplayFilterPresets `json:"display_filter_presets"`
CollectionDefaultSort bool `json:"collection_default_sort"`
CollectionSortPreferences bool `json:"collection_sort_preferences"`
EffectiveCollectionSort bool `json:"effective_collection_sort"`
}
type collectionDisplayFilterPresets struct {
@@ -233,6 +237,9 @@ func (h *CollectionHandler) HandleCapabilities(w http.ResponseWriter, r *http.Re
Watched: []string{"all", "watched", "unwatched"},
Media: []string{"all", "movie", "series"},
},
CollectionDefaultSort: true,
CollectionSortPreferences: true,
EffectiveCollectionSort: true,
})
}
@@ -274,7 +281,11 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt
}
}
queryDefinition := string(queryDefinitionJSON)
sortConfig := string(defaultJSON(req.SortConfig))
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
displayQueryDefinition, err := catalog.NormalizeDisplayQueryFragment(req.DisplayQueryDefinition)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
@@ -358,7 +369,11 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt
input.QueryDefinition = &value
}
if len(req.SortConfig) > 0 {
value := string(req.SortConfig)
value, err := NormalizeCollectionSortConfig(req.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.SortConfig = &value
}
input.IsShared = req.IsShared
@@ -0,0 +1,25 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCollectionCapabilitiesAdvertiseSortSupport(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/collections/capabilities", nil)
NewCollectionHandler(nil).HandleCapabilities(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got collectionCapabilitiesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if !got.CollectionDefaultSort || !got.CollectionSortPreferences || !got.EffectiveCollectionSort {
t.Fatalf("sort capabilities not fully advertised: %+v", got)
}
}
+248 -17
View File
@@ -4,9 +4,15 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -14,7 +20,9 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
// DownloadService is the interface that the download handler depends on. A
@@ -44,14 +52,51 @@ type DownloadService interface {
// DownloadHandler handles download endpoints.
type DownloadHandler struct {
svc DownloadService
svc DownloadService
nodePlanner nodepool.DownloadPlanner
jwtSecret func() string
proxyHTTPClient *http.Client
preflightMu sync.Mutex
preflightCache map[string]proxyPreflightResult
}
type proxyPreflightResult struct {
reachable bool
expiresAt time.Time
}
// NewDownloadHandler creates a new DownloadHandler.
func NewDownloadHandler(svc DownloadService) *DownloadHandler {
return &DownloadHandler{svc: svc}
return &DownloadHandler{
svc: svc,
preflightCache: make(map[string]proxyPreflightResult),
proxyHTTPClient: &http.Client{
Timeout: 3 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// SetProxyDelivery enables stateless proxy-node egress after the API service
// has authorized and resolved a download target. It is optional; integrated
// deployments continue to serve bytes locally.
func (h *DownloadHandler) SetProxyDelivery(planner nodepool.DownloadPlanner, jwtSecret func() string) {
h.nodePlanner = planner
h.jwtSecret = jwtSecret
}
type downloadFileResolver interface {
ResolveDirectFile(ctx context.Context, userID, fileID int, format string, filter catalog.AccessFilter) (*downloads.FileTarget, error)
ResolveManagedFile(ctx context.Context, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (*downloads.FileTarget, error)
}
const (
proxyDownloadTokenTTL = 15 * time.Minute
proxyPreflightCacheTTL = 5 * time.Second
)
// downloadRequest represents the JSON body for POST /downloads.
type downloadRequest struct {
ContentID string `json:"content_id"`
@@ -124,6 +169,7 @@ type downloadCapabilityResponse struct {
SeasonDownload bool `json:"season_download"`
SeriesMonitoring bool `json:"series_monitoring"`
MonitoringModes []string `json:"monitoring_modes,omitempty"`
ProxyDelivery bool `json:"proxy_delivery"`
}
func toDownloadResponse(d *downloads.Download) downloadResponse {
@@ -179,6 +225,7 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques
return
}
_, _, proxyDelivery := h.proxyTarget()
writeJSON(w, http.StatusOK, downloadCapabilityResponse{
Enabled: capInfo.Enabled,
DownloadAllowed: capInfo.DownloadAllowed,
@@ -188,6 +235,7 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques
SeasonDownload: capInfo.SeasonDownload,
SeriesMonitoring: capInfo.SeriesMonitoring,
MonitoringModes: capInfo.MonitoringModes,
ProxyDelivery: proxyDelivery,
})
}
@@ -382,6 +430,17 @@ func (h *DownloadHandler) HandlePatchDownload(w http.ResponseWriter, r *http.Req
// HandleDownloadFile handles GET /downloads/{id}/file.
func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
h.handleDownloadFile(w, r, false)
}
// HandleDownloadFileViaProxy serves the additive proxy-aware download route.
// Clients opt into its 307 response only after discovering proxy_delivery in
// the download capability; the established file route retains 200/206.
func (h *DownloadHandler) HandleDownloadFileViaProxy(w http.ResponseWriter, r *http.Request) {
h.handleDownloadFile(w, r, true)
}
func (h *DownloadHandler) handleDownloadFile(w http.ResponseWriter, r *http.Request, delegate bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -399,35 +458,56 @@ func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Requ
profileID, deviceID, _, _ := managedIdentity(r)
filter := requestAccessFilter(r)
if delegate && deviceID != "" {
handled, err := h.redirectManagedDownload(r.Context(), w, r, userID, profileID, deviceID, id, filter)
if err != nil {
h.writeDownloadFileError(w, r, id, err)
return
}
if handled {
return
}
}
// Full media downloads outlive the server's absolute WriteTimeout; roll
// the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
if err := h.svc.ServeFile(r.Context(), sw, r, userID, profileID, deviceID, id, filter); err != nil {
if errors.Is(err, downloads.ErrNotFound) || errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Download not found")
return
}
if errors.Is(err, downloads.ErrDownloadNotActive) {
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
return
}
if errors.Is(err, downloads.ErrProfileRequired) {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
if errors.Is(err, downloads.ErrFeatureDisabled) || errors.Is(err, downloads.ErrDownloadNotAllowed) {
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
if errors.Is(err, downloads.ErrResponseCommitted) {
return
}
h.writeDownloadFileError(w, r, id, err)
}
}
func (h *DownloadHandler) writeDownloadFileError(w http.ResponseWriter, r *http.Request, id string, err error) {
switch {
case errors.Is(err, downloads.ErrNotFound), errors.Is(err, catalog.ErrItemNotFound):
writeError(w, http.StatusNotFound, "not_found", "Download not found")
case errors.Is(err, downloads.ErrDownloadNotActive):
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
case errors.Is(err, downloads.ErrProfileRequired):
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
case errors.Is(err, downloads.ErrFeatureDisabled), errors.Is(err, downloads.ErrDownloadNotAllowed):
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
default:
slog.ErrorContext(r.Context(), "failed to serve download file", "component", "api", "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download")
return
}
}
// HandleDirectDownload handles GET /direct-download?file_id=N. A legacy
// format=original query is accepted, but direct downloads remain original-only.
func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Request) {
h.handleDirectDownload(w, r, false)
}
// HandleDirectDownloadViaProxy serves the additive proxy-aware direct-download
// route while the established endpoint retains its 200/206 response contract.
func (h *DownloadHandler) HandleDirectDownloadViaProxy(w http.ResponseWriter, r *http.Request) {
h.handleDirectDownload(w, r, true)
}
func (h *DownloadHandler) handleDirectDownload(w http.ResponseWriter, r *http.Request, delegate bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -446,12 +526,163 @@ func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Re
}
filter := requestAccessFilter(r)
if delegate {
if handled, redirectErr := h.redirectDirectDownload(r.Context(), w, r, userID, fileID, r.URL.Query().Get("format"), filter); redirectErr != nil {
h.writeDownloadError(w, redirectErr)
return
} else if handled {
return
}
}
if err := h.svc.ServeDirect(r.Context(), w, r, userID, fileID, r.URL.Query().Get("format"), filter); err != nil {
h.writeDownloadError(w, err)
return
}
}
func (h *DownloadHandler) redirectDirectDownload(ctx context.Context, w http.ResponseWriter, r *http.Request, userID, fileID int, format string, filter catalog.AccessFilter) (bool, error) {
resolver, secret, ok := h.proxyTarget()
if !ok {
return false, nil
}
target, err := resolver.ResolveDirectFile(ctx, userID, fileID, format, filter)
if err != nil {
return false, err
}
return h.redirectToProxy(w, r, secret, target, userID, "")
}
func (h *DownloadHandler) redirectManagedDownload(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (bool, error) {
resolver, secret, ok := h.proxyTarget()
if !ok {
return false, nil
}
target, err := resolver.ResolveManagedFile(ctx, userID, profileID, deviceID, downloadID, filter)
if err != nil {
return false, err
}
return h.redirectToProxy(w, r, secret, target, userID, profileID)
}
func (h *DownloadHandler) proxyTarget() (downloadFileResolver, string, bool) {
resolver, ok := h.svc.(downloadFileResolver)
if !ok || h.nodePlanner == nil || h.jwtSecret == nil {
return nil, "", false
}
secret := strings.TrimSpace(h.jwtSecret())
if secret == "" {
return nil, "", false
}
return resolver, secret, true
}
func (h *DownloadHandler) redirectToProxy(w http.ResponseWriter, r *http.Request, secret string, target *downloads.FileTarget, userID int, profileID string) (bool, error) {
if target == nil || (strings.TrimSpace(target.Path) == "" && target.OriginArtifactID == "") {
return false, fmt.Errorf("download target is empty")
}
if target.OriginArtifactID != "" && strings.TrimSpace(target.OriginNodeURL) == "" {
return false, nil
}
if !target.ProxyEligible {
return false, nil
}
reservationKey := target.DownloadID
if reservationKey == "" {
reservationKey = fmt.Sprintf("direct-%d-%d", userID, target.MediaFileID)
}
sessionID := fmt.Sprintf("download-%s-%d", reservationKey, time.Now().UnixNano())
plan := h.nodePlanner.PlanDownload(sessionID, target.OriginNodeGroup)
if plan.ProxyNode == nil {
return false, nil
}
releaseReservation := func() { h.nodePlanner.ReleaseSession(sessionID) }
mediaPath := target.Path
downloadFilename := ""
if target.OriginArtifactID != "" {
mediaPath = ""
if strings.TrimSpace(target.Path) != "" {
downloadFilename = filepath.Base(target.Path)
}
}
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: sessionID,
MediaPath: mediaPath,
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: target.OriginNodeURL,
DownloadArtifactID: target.OriginArtifactID,
DownloadArtifactRowID: target.ArtifactID,
DownloadFilename: downloadFilename,
UserID: userID,
ProfileID: profileID,
MediaFileID: target.MediaFileID,
}, secret, proxyDownloadTokenTTL)
if err != nil {
releaseReservation()
return false, fmt.Errorf("sign proxy download token: %w", err)
}
location := strings.TrimRight(plan.ProxyNode.URL, "/") + "/downloads/file/" + url.PathEscape(token)
targetKey := target.Path
if target.OriginArtifactID != "" {
targetKey = target.OriginNodeURL + "\x00" + target.OriginArtifactID
}
cacheKey := strings.TrimRight(plan.ProxyNode.URL, "/") + "\x00" + targetKey
if !h.proxyCanServe(r.Context(), cacheKey, location) {
releaseReservation()
return false, nil
}
// A HEAD request only proves that the proxy can read the selected path; the
// proxy deliberately does not count it as an active transfer. Release the
// provisional slot before redirecting so a probe cannot hold max_jobs
// capacity until the planner's reservation bridge expires.
if r.Method == http.MethodHead {
releaseReservation()
}
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
return true, nil
}
func (h *DownloadHandler) proxyCanServe(ctx context.Context, cacheKey, location string) bool {
now := time.Now()
h.preflightMu.Lock()
for key, cached := range h.preflightCache {
if !now.Before(cached.expiresAt) {
delete(h.preflightCache, key)
}
}
if cached, ok := h.preflightCache[cacheKey]; ok && now.Before(cached.expiresAt) {
h.preflightMu.Unlock()
return cached.reachable
}
h.preflightMu.Unlock()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, location, nil)
if err != nil {
return false
}
client := h.proxyHTTPClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
reachable := false
if err != nil {
slog.DebugContext(ctx, "download proxy preflight failed; serving locally", "component", "api", "error", err)
} else {
_ = resp.Body.Close()
reachable = resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices
}
if err == nil && !reachable {
slog.DebugContext(ctx, "download proxy preflight unavailable; serving locally", "component", "api", "status", resp.StatusCode)
}
h.preflightMu.Lock()
if h.preflightCache == nil {
h.preflightCache = make(map[string]proxyPreflightResult)
}
h.preflightCache[cacheKey] = proxyPreflightResult{reachable: reachable, expiresAt: now.Add(proxyPreflightCacheTTL)}
h.preflightMu.Unlock()
return reachable
}
// requireManaged validates a managed (device-scoped) request: authentication, a
// configured service, and the device + profile identity (device_id from the
// X-Silo-Device-Id header only, never the body). On failure it writes the error
+330
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -14,6 +16,8 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
// fakeDownloadService is a programmable DownloadService for handler tests. It
@@ -31,6 +35,7 @@ type fakeDownloadService struct {
deleteErr error
patchErr error
serveErr error
serveBody string
directErr error
manifest *downloads.OfflineManifest
batchManifests []*downloads.OfflineManifest
@@ -81,6 +86,21 @@ type identityCall struct {
downloadID string
}
type proxyDownloadService struct {
*fakeDownloadService
directTarget *downloads.FileTarget
managedTarget *downloads.FileTarget
resolveErr error
}
func (s *proxyDownloadService) ResolveDirectFile(context.Context, int, int, string, catalog.AccessFilter) (*downloads.FileTarget, error) {
return s.directTarget, s.resolveErr
}
func (s *proxyDownloadService) ResolveManagedFile(context.Context, int, string, string, string, catalog.AccessFilter) (*downloads.FileTarget, error) {
return s.managedTarget, s.resolveErr
}
func (f *fakeDownloadService) Capability(context.Context, int) (downloads.Capability, error) {
return f.capability, nil
}
@@ -166,6 +186,9 @@ func (f *fakeDownloadService) ServeDirect(_ context.Context, w http.ResponseWrit
func (f *fakeDownloadService) ServeFile(_ context.Context, w http.ResponseWriter, _ *http.Request, userID int, profileID, deviceID, downloadID string, _ catalog.AccessFilter) error {
f.gotServe = identityCall{userID, profileID, deviceID, downloadID}
if f.serveBody != "" {
_, _ = w.Write([]byte(f.serveBody))
}
if f.serveErr != nil {
return f.serveErr
}
@@ -283,6 +306,25 @@ func TestHandleCapability(t *testing.T) {
if len(resp.QualityPresets) != 1 || resp.QualityPresets[0] != downloads.QualityOriginal {
t.Fatalf("quality presets = %v, want [original]", resp.QualityPresets)
}
if resp.ProxyDelivery {
t.Fatal("proxy delivery advertised without a configured planner")
}
}
func TestHandleCapabilityAdvertisesAdditiveProxyDelivery(t *testing.T) {
svc := &proxyDownloadService{fakeDownloadService: &fakeDownloadService{}}
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(nodepool.NewProxyPool(), nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleCapability(rec, downloadTestRequest(http.MethodGet, "/downloads/capability", nil, 7, "", ""))
var resp downloadCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.ProxyDelivery {
t.Fatalf("capability = %+v", resp)
}
}
func TestHandleCapabilityUnauthorized(t *testing.T) {
@@ -429,6 +471,139 @@ func TestManagedFileThreadsIdentity(t *testing.T) {
}
}
func TestManagedFileDoesNotAppendJSONAfterRelayResponseCommitted(t *testing.T) {
svc := &fakeDownloadService{
serveBody: "partial",
serveErr: fmt.Errorf("remote relay failed: %w", downloads.ErrResponseCommitted),
}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/file", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDownloadFile(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "partial" {
t.Fatalf("response status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestManagedFileRedirectsAuthorizedArtifactToProxy(t *testing.T) {
const secret = "download-proxy-secret"
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
t.Errorf("preflight method = %s", r.Method)
}
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{Path: "/artifacts/movie.mp4", DownloadID: "dl1", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return secret })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/file-proxy", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307 (body: %s)", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
prefix := proxy.URL + "/downloads/file/"
if !strings.HasPrefix(location, prefix) {
t.Fatalf("Location = %q", location)
}
claims, err := streamtoken.Verify(strings.TrimPrefix(location, prefix), secret)
if err != nil {
t.Fatal(err)
}
if claims.PlayMethod != streamtoken.PlayMethodDownload || claims.MediaPath != "/artifacts/movie.mp4" || claims.UserID != 7 || claims.ProfileID != "pA" {
t.Fatalf("claims = %+v", claims)
}
if svc.gotServe != (identityCall{}) {
t.Fatalf("local ServeFile was called: %+v", svc.gotServe)
}
}
func TestManagedFileRedirectsNodeLocalArtifactThroughSameGroupProxy(t *testing.T) {
const secret = "download-proxy-secret"
proxyA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("wrong-group proxy was selected")
w.WriteHeader(http.StatusOK)
}))
defer proxyA.Close()
proxyB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
t.Errorf("preflight method = %s", r.Method)
}
w.WriteHeader(http.StatusOK)
}))
defer proxyB.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{
DownloadID: "dl-remote",
ArtifactID: "artifact-row",
Path: "/prepared/Movie Final.mp4",
MediaFileID: 42,
OriginNodeURL: "http://transcode-b.internal:8096",
OriginNodeGroup: "host-b",
OriginArtifactID: "artifact-remote",
ProxyEligible: true,
},
}
groupA, groupB := "host-a", "host-b"
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{
{URL: proxyA.URL, Group: &groupA, Enabled: true, Healthy: true},
{URL: proxyB.URL, Group: &groupB, Enabled: true, Healthy: true},
})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return secret })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl-remote/file-proxy", nil, 7, "pA", "devA"), "dl-remote")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
prefix := proxyB.URL + "/downloads/file/"
if !strings.HasPrefix(location, prefix) {
t.Fatalf("Location = %q, want proxy-b", location)
}
claims, err := streamtoken.Verify(strings.TrimPrefix(location, prefix), secret)
if err != nil {
t.Fatal(err)
}
if claims.MediaPath != "" || claims.DownloadFilename != "Movie Final.mp4" || claims.TranscodeNode != "http://transcode-b.internal:8096" ||
claims.DownloadArtifactID != "artifact-remote" || claims.DownloadArtifactRowID != "artifact-row" {
t.Fatalf("claims = %+v", claims)
}
}
func TestManagedFileFallsBackWhenRemoteLocatorHasNoOriginURL(t *testing.T) {
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{
DownloadID: "dl-incomplete",
OriginArtifactID: "artifact-without-origin",
ProxyEligible: true,
},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: "http://proxy.invalid", Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl-incomplete/file-proxy", nil, 7, "pA", "devA"), "dl-incomplete")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "served" {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestManagedListThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
@@ -514,6 +689,161 @@ func TestHandleDirectDownloadThreadsOriginalFormat(t *testing.T) {
}
}
func TestHandleDirectDownloadViaProxyRedirectsToProxy(t *testing.T) {
const secret = "download-proxy-secret"
maxJobs := 1
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL + "/", Enabled: true, Healthy: true, MaxJobs: &maxJobs}})
planner := nodepool.NewPlanner(proxies, nodepool.NewTranscodePool())
h := NewDownloadHandler(svc)
h.SetProxyDelivery(planner, func() string { return secret })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodHead, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307 (body: %s)", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
claims, err := streamtoken.Verify(strings.TrimPrefix(location, proxy.URL+"/downloads/file/"), secret)
if err != nil {
t.Fatal(err)
}
if claims.MediaPath != "/media/movie.mkv" || claims.MediaFileID != 42 {
t.Fatalf("claims = %+v", claims)
}
if svc.gotDirectFileID != 0 {
t.Fatalf("local ServeDirect called for file %d", svc.gotDirectFileID)
}
// HEAD never becomes an active proxy transfer, so it must return the only
// available slot immediately rather than waiting for reservation expiry.
if plan := planner.PlanDownload("after-head"); plan.ProxyNode == nil {
t.Fatal("HEAD request retained the proxy's only job slot")
}
planner.ReleaseSession("after-head")
}
func TestHandleDirectDownloadFallsBackWithoutHealthyProxy(t *testing.T) {
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: "https://proxy.example", Enabled: true, Healthy: false}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 {
t.Fatalf("status = %d file = %d body = %s", rec.Code, svc.gotDirectFileID, rec.Body.String())
}
}
func TestHandleDirectDownloadFallsBackWhenProxyCannotServePath(t *testing.T) {
proxy := httptest.NewServer(http.NotFoundHandler())
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 {
t.Fatalf("status = %d file = %d body = %s", rec.Code, svc.gotDirectFileID, rec.Body.String())
}
}
func TestHandleDirectDownloadEstablishedRouteKeepsLocalSuccessStatus(t *testing.T) {
proxyRequests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyRequests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownload(rec, downloadTestRequest(http.MethodGet, "/direct-download?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 || proxyRequests != 0 {
t.Fatalf("status = %d file = %d proxy requests = %d", rec.Code, svc.gotDirectFileID, proxyRequests)
}
}
func TestHandleDirectDownloadViaProxyStaysLocalWhenTargetIneligible(t *testing.T) {
proxyRequests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyRequests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: false},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 || proxyRequests != 0 {
t.Fatalf("status = %d file = %d proxy requests = %d", rec.Code, svc.gotDirectFileID, proxyRequests)
}
}
func TestHandleDirectDownloadViaProxyMapsResolverError(t *testing.T) {
svc := &proxyDownloadService{fakeDownloadService: &fakeDownloadService{}, resolveErr: catalog.ErrItemNotFound}
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(nodepool.NewProxyPool(), nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestProxyPreflightCachesReachabilityByNodeAndPath(t *testing.T) {
requests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
h := NewDownloadHandler(&fakeDownloadService{})
location := proxy.URL + "/downloads/file/token"
if !h.proxyCanServe(context.Background(), proxy.URL+"\x00/media/movie.mkv", location) {
t.Fatal("first preflight failed")
}
if !h.proxyCanServe(context.Background(), proxy.URL+"\x00/media/movie.mkv", location) {
t.Fatal("cached preflight failed")
}
if requests != 1 {
t.Fatalf("preflight requests = %d, want 1", requests)
}
}
func TestHandleDirectDownloadMissingFileID(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
rec := httptest.NewRecorder()
+32 -12
View File
@@ -2115,6 +2115,7 @@ func filterSortClause(sort, order string) string {
}
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
selectedFileID := 0
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
@@ -2141,6 +2142,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -2168,6 +2170,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -2261,17 +2264,42 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
}}, nil
}
// episodeTargets resolves the watched leaf targets for a season or series.
// Durations come from one batched file lookup rather than a query per episode:
// marking a long series used to issue one GetByEpisodeID per episode before it
// wrote anything.
func (h *ItemsHandler) episodeTargets(ctx context.Context, episodes []*models.Episode) []watchedLeafTarget {
episodeIDs := make([]string, 0, len(episodes))
for _, episode := range episodes {
episodeIDs = append(episodeIDs, episode.ContentID)
}
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
targets := make([]watchedLeafTarget, 0, len(episodes))
for _, episode := range episodes {
targets = append(targets, watchedLeafTarget{
ContentID: episode.ContentID,
DurationSeconds: h.contentDurationSeconds(ctx, "", episode.ContentID, episode.Runtime),
DurationSeconds: mediaFileDurationSeconds(filesByEpisode[episode.ContentID], episode.Runtime),
})
}
return targets
}
// mediaFileDurationSeconds picks a playable duration from an item's files,
// falling back to the catalog runtime. Mirrors contentDurationSeconds's
// preference order so the batched and single-item paths agree.
func mediaFileDurationSeconds(files []*models.MediaFile, fallbackRuntimeMinutes int) float64 {
for _, file := range files {
if file != nil && file.Duration > 0 {
return float64(file.Duration)
}
}
if fallbackRuntimeMinutes > 0 {
return float64(fallbackRuntimeMinutes * 60)
}
return 0
}
func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, episodeID string, fallbackRuntimeMinutes int) float64 {
if h.fileRepo == nil {
if fallbackRuntimeMinutes > 0 {
@@ -2291,18 +2319,10 @@ func (h *ItemsHandler) contentDurationSeconds(ctx context.Context, contentID, ep
case contentID != "":
files, err = h.fileRepo.GetByContentID(ctx, contentID)
}
if err == nil {
for _, file := range files {
if file != nil && file.Duration > 0 {
return float64(file.Duration)
}
}
if err != nil {
files = nil
}
if fallbackRuntimeMinutes > 0 {
return float64(fallbackRuntimeMinutes * 60)
}
return 0
return mediaFileDurationSeconds(files, fallbackRuntimeMinutes)
}
// isNotFound checks if an error is a "not found" sentinel.
+106 -60
View File
@@ -33,26 +33,28 @@ import (
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type LibraryCollectionHandler struct {
repo *catalog.LibraryCollectionRepository
service *catalog.LibraryCollectionService
itemRepo *catalog.ItemRepository
Executor *catalog.QueryExecutor
detailSvc *catalog.DetailService
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
FolderRepo *catalog.FolderRepository
TemplateRegistry *templates.Registry
SmartCountRefresher *catalog.SmartCountRefresher
JobRepo *adminjob.Repository
EventsHub *evt.Hub
repo *catalog.LibraryCollectionRepository
service *catalog.LibraryCollectionService
itemRepo *catalog.ItemRepository
Executor *catalog.QueryExecutor
detailSvc *catalog.DetailService
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
FolderRepo *catalog.FolderRepository
TemplateRegistry *templates.Registry
SmartCountRefresher *catalog.SmartCountRefresher
JobRepo *adminjob.Repository
EventsHub *evt.Hub
SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner
}
var errLibraryCollectionInUse = errors.New("collection is used by one or more sections")
@@ -377,21 +379,22 @@ type updateLibraryCollectionRequest struct {
}
type importMDBListRequest struct {
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
// PosterFromTemplate marks the poster as supplied by a template bundle so
// future template applies can refresh it. Set programmatically by
// createCollectionFromTemplate; not user-settable via JSON.
@@ -399,24 +402,25 @@ type importMDBListRequest struct {
}
type importTMDBRequest struct {
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
TimeWindow string `json:"time_window"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
PosterFromTemplate bool `json:"-"`
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
TimeWindow string `json:"time_window"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
SortOrder int `json:"sort_order,omitempty"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
ManagementMode string `json:"management_mode,omitempty"`
ManagementSource string `json:"management_source,omitempty"`
ManagementKey string `json:"management_key,omitempty"`
PosterFromTemplate bool `json:"-"`
}
// importTMDBFranchiseRequest is the request body for creating a collection
@@ -487,13 +491,14 @@ type importTMDBDiscoverSpecBody struct {
}
type importTraktRequest struct {
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
ProfileID string `json:"profile_id,omitempty"`
SortConfig json.RawMessage `json:"sort_config,omitempty"`
LibraryID int `json:"library_id"`
LibraryIDs []int `json:"library_ids"`
Title string `json:"title"`
Description string `json:"description"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
ProfileID string `json:"profile_id,omitempty"`
// ListURL selects a user-authored Trakt list
// (https://trakt.tv/users/{user}/lists/{slug}) instead of a preset.
ListURL string `json:"list_url,omitempty"`
@@ -1137,6 +1142,14 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr
}
}
// Library collections resolve with user scope stripped, so per-profile sort
// fields are rejected here rather than failing at browse time.
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
var syncSchedule *string
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
if err := catalog.ParseCronExpression(s); err != nil {
@@ -1166,7 +1179,7 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: defaultJSON(req.SortConfig),
SortConfig: json.RawMessage(sortConfig),
SourceConfig: defaultCollectionSourceConfig(req.SourceConfig),
ManagementMode: managementMode,
ManagementSource: managementSource,
@@ -1225,6 +1238,16 @@ func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWr
}
}
sortConfig := req.SortConfig
if len(req.SortConfig) > 0 {
normalized, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sortConfig = json.RawMessage(normalized)
}
// Validate sync_schedule if provided.
if req.SyncSchedule != nil && *req.SyncSchedule != "" {
if err := catalog.ParseCronExpression(*req.SyncSchedule); err != nil {
@@ -1257,7 +1280,7 @@ func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWr
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: req.SortConfig,
SortConfig: sortConfig,
SourceConfig: req.SourceConfig,
ManagementMode: normalizeOptionalCollectionManagementMode(req.ManagementMode),
ManagementSource: req.ManagementSource,
@@ -1431,7 +1454,13 @@ func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, c
}
}
return h.repo.Delete(ctx, collectionID)
if err := h.repo.Delete(ctx, collectionID); err != nil {
return err
}
if h.SortPreferenceCleaner != nil {
h.SortPreferenceCleaner.DeleteForCollection(ctx, userstore.CollectionKindLibrary, collectionID)
}
return nil
}
type adminReorderCollectionsRequest struct {
@@ -2636,6 +2665,10 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
if err != nil {
return nil, requestValidationError{err: err}
}
sortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
return nil, requestValidationError{err: err}
}
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -2643,6 +2676,7 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
Title: req.Title,
Description: req.Description,
CollectionType: "mdblist",
SortConfig: json.RawMessage(sortConfig),
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
@@ -2696,6 +2730,10 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
if err != nil {
return nil, requestValidationError{err: err}
}
tmdbSortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
return nil, requestValidationError{err: err}
}
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -2703,6 +2741,7 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
Title: req.Title,
Description: req.Description,
CollectionType: "tmdb",
SortConfig: json.RawMessage(tmdbSortConfig),
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
@@ -3048,6 +3087,12 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
return
}
traktSortConfig, err := NormalizeCollectionSortConfig(req.SortConfig, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
@@ -3055,6 +3100,7 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
Title: req.Title,
Description: req.Description,
CollectionType: "trakt",
SortConfig: json.RawMessage(traktSortConfig),
Visibility: "visible",
Featured: req.Featured,
PosterURL: req.PosterURL,
File diff suppressed because it is too large Load Diff
@@ -51,16 +51,22 @@ func TestPlaybackSessionMissingResponsesUseStableErrorCode(t *testing.T) {
handle: playbackHandler.HandleStopPlayback,
},
{
name: "audio",
name: "replan",
request: func() *http.Request {
return playbackTestRequest(
http.MethodPatch,
"/api/v1/playback/"+missingSessionID+"/audio",
[]byte(`{"audio_track_index":1}`),
http.MethodPost,
"/api/v1/playback/"+missingSessionID+"/replan",
// A fully valid replan body: the point of this case is the
// missing session, so nothing earlier may reject it.
[]byte(`{"protocol_version":3,"playback_attempt_id":"attempt-00000001",`+
`"replan_request_id":"replan-00000001","failed_plan_id":"plan:00000000000000",`+
`"plan_attempt_id":"plan-attempt-1","plan_attempt_key":"v3:0000000000000001",`+
`"attempt_count":1,"failure":{"classification":"decoder_error"},`+
`"client_capabilities":{"video_evidence":"exact","audio_evidence":"exact"}}`),
map[string]string{"session_id": missingSessionID},
)
},
handle: playbackHandler.HandleChangeAudioTrack,
handle: playbackHandler.HandleReplanPlaybackV3,
},
{
name: "websocket",
@@ -98,18 +104,6 @@ func TestPlaybackSessionMissingResponsesUseStableErrorCode(t *testing.T) {
},
handle: streamHandler.HandleSubtitle,
},
{
name: "transcode start",
request: func() *http.Request {
return playbackTestRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
[]byte(`{"session_id":"`+missingSessionID+`","seek_seconds":0,"target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
nil,
)
},
handle: playbackHandler.HandleStartTranscode,
},
{
name: "transcode manifest",
request: func() *http.Request {
+58 -2
View File
@@ -48,7 +48,10 @@ type playbackSessionRow struct {
ClientIP string `json:"client_ip,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
ClientBuild string `json:"client_build,omitempty"`
ClientChannel string `json:"client_channel,omitempty"`
ClientLabel string `json:"client_label,omitempty"`
ClientLabelFull string `json:"client_label_full,omitempty"`
ClientUserAgent string `json:"client_user_agent,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TranscodeAudio bool `json:"transcode_audio"`
@@ -89,6 +92,11 @@ type playbackSessionsCapabilitiesResponse struct {
EffectivePlayMethodValues []string `json:"effective_play_method_values"`
// IsJellyfinClient reports that rows carry is_jellyfin_client.
IsJellyfinClient bool `json:"is_jellyfin_client"`
// ClientBuild reports that rows carry client_build (and the exact-version
// client_label_full derived from it).
ClientBuild bool `json:"client_build"`
// ClientChannel reports that rows carry client_channel.
ClientChannel bool `json:"client_channel"`
}
// HandleGetSessionsCapabilities exposes additive feature support for the live
@@ -98,6 +106,8 @@ func (h *AdminHandler) HandleGetSessionsCapabilities(w http.ResponseWriter, _ *h
EffectivePlayMethod: true,
EffectivePlayMethodValues: []string{"direct", "remux", "transcode", "audio"},
IsJellyfinClient: true,
ClientBuild: true,
ClientChannel: true,
})
}
@@ -182,6 +192,8 @@ func (l *PlaybackSessionsLoader) Load(
COALESCE(HOST(s.client_ip), ''),
COALESCE(s.client_name, ''),
COALESCE(s.client_version, ''),
COALESCE(s.client_build, ''),
COALESCE(s.client_channel, ''),
COALESCE(s.client_user_agent, ''),
COALESCE(s.audio_track_index, 0),
COALESCE(s.transcode_audio, FALSE),
@@ -239,6 +251,7 @@ func (l *PlaybackSessionsLoader) Load(
&posterPath,
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.ClientName, &s.ClientVersion,
&s.ClientBuild, &s.ClientChannel,
&s.ClientUserAgent, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
@@ -253,6 +266,13 @@ func (l *PlaybackSessionsLoader) Load(
s.SourceBitrateKbps = sourceBitrateKbps
s.SourceAudioChannels = sourceAudioChannels
s.ClientLabel = playbackClientDisplayName(s.ClientName, s.ClientVersion, s.ClientUserAgent)
// Only worth the bytes when it says more than the compact label — which
// it does not for any client without a build or a non-release channel,
// i.e. most of a 200-row page. Clients read client_label when
// client_label_full is absent, so omitting the duplicate costs nothing.
if full := playbackClientFullDisplayName(s.ClientName, s.ClientVersion, s.ClientBuild, s.ClientChannel, s.ClientUserAgent); full != s.ClientLabel {
s.ClientLabelFull = full
}
enrichPlaybackSessionRow(&s, audioTracksJSON)
sessions = append(sessions, s)
}
@@ -447,11 +467,47 @@ func firstNonEmptyValue(values ...string) string {
return ""
}
// playbackClientFullDisplayName renders the client's exact identity — version,
// opaque build, and non-default channel — for surfaces that can afford the
// width (expanded session details, tooltips). The name-and-version half is
// whatever the compact label resolved, so a client identified only by its user
// agent still gets its build named: a client that bothered to report a build
// has earned having it displayed, whether or not it also named itself.
func playbackClientFullDisplayName(name, version, build, channel, userAgent string) string {
label := playbackClientDisplayName(name, version, userAgent)
if label == "" {
return ""
}
build = strings.TrimSpace(build)
channel = strings.TrimSpace(channel)
// "release" is the assumed channel, so naming it adds width without adding
// information; every other channel is worth calling out.
if strings.EqualFold(channel, "release") {
channel = ""
}
var qualifiers []string
if build != "" {
qualifiers = append(qualifiers, "build "+build)
}
if channel != "" {
qualifiers = append(qualifiers, channel)
}
if len(qualifiers) > 0 {
label += " (" + strings.Join(qualifiers, ", ") + ")"
}
return label
}
// playbackClientDisplayName renders the compact label the session lists show.
// A client that reports its own name keeps its version verbatim — truncating
// "1.0.0" to "1" hid the only field that identifies the running build. Labels
// derived from a user agent stay shortened, where a full browser version
// ("120.0.6099.109") is noise.
func playbackClientDisplayName(name, version, userAgent string) string {
name = strings.TrimSpace(name)
version = shortPlaybackClientVersion(version)
if name != "" {
if version != "" {
if version = strings.TrimSpace(version); version != "" {
return name + " " + version
}
return name
+274 -2
View File
@@ -4,7 +4,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
@@ -65,8 +70,8 @@ func TestSessionsCapabilitiesAdvertisesActivityFields(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient {
t.Fatalf("capabilities must advertise both fields: %+v", resp)
if !resp.EffectivePlayMethod || !resp.IsJellyfinClient || !resp.ClientBuild || !resp.ClientChannel {
t.Fatalf("capabilities must advertise every additive field: %+v", resp)
}
want := []string{"direct", "remux", "transcode", "audio"}
if len(resp.EffectivePlayMethodValues) != len(want) {
@@ -213,6 +218,273 @@ func TestPlaybackClientDisplayNameAndroidDevices(t *testing.T) {
}
}
func TestPlaybackClientDisplayNameKeepsNamedClientVersionExact(t *testing.T) {
cases := []struct {
name string
clientName string
clientVersion string
want string
}{
{name: "patch version survives", clientName: "Silo Android TV", clientVersion: "1.0.0", want: "Silo Android TV 1.0.0"},
{name: "prerelease suffix survives", clientName: "Silo iOS", clientVersion: "2.1.0-rc.3", want: "Silo iOS 2.1.0-rc.3"},
{name: "no version", clientName: "Silo Android TV", clientVersion: "", want: "Silo Android TV"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientDisplayName(tc.clientName, tc.clientVersion, "")
if got != tc.want {
t.Fatalf("playbackClientDisplayName(%q, %q, \"\") = %q, want %q", tc.clientName, tc.clientVersion, got, tc.want)
}
})
}
}
func TestPlaybackClientFullDisplayName(t *testing.T) {
cases := []struct {
name string
client string
version string
build string
channel string
userAgent string
want string
}{
{
name: "version build and channel",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "dev",
want: "Silo Android TV 1.0.0 (build 5, dev)",
},
{
name: "release channel omitted",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "release",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "release channel omitted case insensitively",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
channel: "Release",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "empty channel omitted",
client: "Silo Android TV",
version: "1.0.0",
build: "5",
want: "Silo Android TV 1.0.0 (build 5)",
},
{
name: "channel without build",
client: "Silo Android TV",
version: "1.0.0",
channel: "dev",
want: "Silo Android TV 1.0.0 (dev)",
},
{
name: "build and channel empty",
client: "Silo Android TV",
version: "1.0.0",
want: "Silo Android TV 1.0.0",
},
{
name: "version empty",
client: "Silo Android TV",
want: "Silo Android TV",
},
{
name: "opaque build is not parsed",
client: "Silo tvOS",
version: "1.0.0",
build: "2026.08.13-abcdef",
want: "Silo tvOS 1.0.0 (build 2026.08.13-abcdef)",
},
{
// A client that reports a build but not a name is still named by its
// build: the user-agent label supplies the product half, and dropping
// the qualifiers would hide the exact field this surface exists for.
name: "no client name keeps build and channel on the user agent label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
build: "5",
channel: "dev",
want: "Chrome 120 (build 5, dev)",
},
{
name: "no client name and no qualifiers keeps the plain user agent label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
want: "Chrome 120",
},
{
name: "no client name and no user agent",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientFullDisplayName(tc.client, tc.version, tc.build, tc.channel, tc.userAgent)
if got != tc.want {
t.Fatalf("playbackClientFullDisplayName() = %q, want %q", got, tc.want)
}
})
}
}
func TestPlaybackClientInfoForStartV3(t *testing.T) {
cases := []struct {
name string
headers map[string]string
context playback.ClientPlaybackContextV3
want playback.ClientInfo
}{
{
name: "headers win over the body",
headers: map[string]string{"X-Silo-Client": "Silo iOS", "X-Silo-Client-Version": "2.1.0", "X-Silo-Client-Build": "9", "X-Silo-Client-Channel": "beta"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "1", AppChannel: "release"},
want: playback.ClientInfo{Name: "Silo iOS", Version: "2.1.0", Build: "9", Channel: "beta"},
},
{
// The fallback is per field, not per struct: a client may set the
// name and version headers on every request and still report the
// build it only knows at start time in the body.
name: "body fills only the fields the headers omit",
headers: map[string]string{"X-Silo-Client": "Silo tvOS", "X-Silo-Client-Version": "2.1.0"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "77", AppChannel: "sideload"},
want: playback.ClientInfo{Name: "Silo tvOS", Version: "2.1.0", Build: "77", Channel: "sideload"},
},
{
name: "body supplies everything but the name",
headers: map[string]string{"X-Silo-Client": "Silo Android TV"},
context: playback.ClientPlaybackContextV3{AppVersion: "1.0.0", AppBuild: "5", AppChannel: "dev"},
want: playback.ClientInfo{Name: "Silo Android TV", Version: "1.0.0", Build: "5", Channel: "dev"},
},
{
// The regression this guards: the web player reports the literal
// "web" as its app_version and sends no X-Silo-Client. Taking the
// body anyway would stamp "web" onto client_version — the one field
// the contract promises is a marketing version — for every browser
// session, and onto every route event and decision log with it.
name: "nameless client keeps the body out of client_version",
context: playback.ClientPlaybackContextV3{AppVersion: "web"},
want: playback.ClientInfo{},
},
{
name: "nameless client takes no build or channel either",
context: playback.ClientPlaybackContextV3{AppVersion: "web", AppBuild: "5", AppChannel: "dev"},
want: playback.ClientInfo{},
},
{
name: "whitespace-only header falls through to the body",
headers: map[string]string{"X-Silo-Client": "Silo iOS", "X-Silo-Client-Build": " "},
context: playback.ClientPlaybackContextV3{AppBuild: " 42 "},
want: playback.ClientInfo{Name: "Silo iOS", Build: "42"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
for name, value := range tc.headers {
req.Header.Set(name, value)
}
got := playbackClientInfoForStartV3(req, tc.context)
got.UserAgent = ""
if got != tc.want {
t.Fatalf("playbackClientInfoForStartV3() = %+v, want %+v", got, tc.want)
}
})
}
}
// TestPlaybackClientInfoFromRequestClampsHeaders pins the bound at the request
// boundary rather than at session creation. The resolved identity is written
// straight to the plan-decision log and to playback_route_events, so a client
// sending a header-sized build would reach both if the clamp only happened
// where the session stamps its fields.
func TestPlaybackClientInfoFromRequestClampsHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
req.Header.Set("X-Silo-Client", " "+strings.Repeat("N", 200)+" ")
req.Header.Set("X-Silo-Client-Version", strings.Repeat("v", 100))
req.Header.Set("X-Silo-Client-Build", strings.Repeat("b", 100))
req.Header.Set("X-Silo-Client-Channel", strings.Repeat("c", 100))
got := playbackClientInfoFromRequest(req)
for _, tc := range []struct {
field string
value string
want int
}{
{"Name", got.Name, 128},
{"Version", got.Version, 64},
{"Build", got.Build, 64},
{"Channel", got.Channel, 32},
} {
if len(tc.value) != tc.want {
t.Errorf("%s length = %d, want %d", tc.field, len(tc.value), tc.want)
}
}
}
// TestNormalizeClientMetadataCountsRunes guards the two ways a clamp can go
// wrong on a client-supplied header: the published bound is JSON Schema
// maxLength, which counts characters, and a byte-wise cut through a multi-byte
// rune yields invalid UTF-8 that Postgres refuses — failing the whole per-node
// session-sync transaction, not just the offending row.
func TestNormalizeClientMetadataCountsRunes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
// 40 three-byte runes: within the 64-character channel bound as the schema
// counts it, well past it as bytes.
req.Header.Set("X-Silo-Client-Channel", strings.Repeat("δ", 40))
got := playbackClientInfoFromRequest(req)
if runes := utf8.RuneCountInString(got.Channel); runes != 32 {
t.Errorf("Channel runes = %d, want the 32-character bound", runes)
}
if !utf8.ValidString(got.Channel) {
t.Errorf("Channel = %q is not valid UTF-8; a text column would reject it", got.Channel)
}
}
// TestPlaybackClientInfoStripsControlCharacters guards the one malformed value
// that reaches a text column looking perfectly well-formed. A JSON NUL escape in a
// v3 start body decodes to a real NUL, which is valid UTF-8 — so the UTF-8
// repair above leaves it and TrimSpace does not consider it whitespace — but
// Postgres refuses NUL in text. The per-node session upserts share one
// transaction, so one such start would stop every live session on that node
// from reconciling. Headers cannot carry it (net/http rejects bytes below
// 0x20), which is exactly why the body path needs its own guard.
func TestPlaybackClientInfoStripsControlCharacters(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", nil)
req.Header.Set("X-Silo-Client", "Silo Android TV")
got := playbackClientInfoForStartV3(req, playback.ClientPlaybackContextV3{
AppVersion: "1.0\x00.0",
AppBuild: "5\x00",
AppChannel: "de\nv",
})
if want := "1.0.0"; got.Version != want {
t.Errorf("Version = %q, want %q", got.Version, want)
}
if want := "5"; got.Build != want {
t.Errorf("Build = %q, want %q", got.Build, want)
}
if want := "dev"; got.Channel != want {
t.Errorf("Channel = %q, want %q", got.Channel, want)
}
for _, field := range []string{got.Name, got.Version, got.Build, got.Channel} {
if strings.ContainsFunc(field, unicode.IsControl) {
t.Errorf("%q still carries a control character; a text column would reject it", field)
}
}
}
func TestEnrichPlaybackSessionRowUsesCompatOrigin(t *testing.T) {
row := playbackSessionRow{
ClientName: "Unrecognized Client",
@@ -1,160 +0,0 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func TestSubtitleURLExt(t *testing.T) {
cases := []struct {
codec string
want string
}{
{"ass", ".ass"},
{"ssa", ".ass"},
{"pgs", ".sup"},
{"hdmv_pgs_subtitle", ".sup"},
{"subrip", ".vtt"},
{"srt", ".vtt"},
{"", ".vtt"},
}
for _, tc := range cases {
if got := subtitleURLExt(tc.codec); got != tc.want {
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
}
}
}
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
file := &models.MediaFile{ID: 42}
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
if len(urls) != 1 {
t.Fatalf("downloaded subtitle URLs = %#v", urls)
}
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
}
}
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
file := &models.MediaFile{
ID: 42,
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "subrip"},
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
},
}
urls := buildSubtitleURLs("sess-1", file, nil, true)
// Every bitmap track is deliverable now that server-side burn-in supports
// bitmap codecs; PGS additionally streams as .sup for client rendering.
if len(urls) != 4 {
t.Fatalf("expected 4 subtitle URLs (text + PGS + DVD + DVB), got %d: %+v", len(urls), urls)
}
srt := urls[0]
if srt.MediaFileID != file.ID || srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt?file_id=42" {
t.Errorf("unexpected text track entry: %+v", srt)
}
pgs := urls[1]
if pgs.MediaFileID != file.ID || pgs.Codec != "hdmv_pgs_subtitle" {
t.Errorf("expected PGS track to be included, got %+v", pgs)
}
if pgs.URL != "/stream/sess-1/subtitles/1.sup?file_id=42" {
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
}
if pgs.FontBundleURL != "" {
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
}
if dvd := urls[2]; dvd.Codec != "dvd_subtitle" || dvd.Index != 2 {
t.Errorf("expected DVD bitmap track to be listed for burn-in selection, got %+v", dvd)
}
if dvb := urls[3]; dvb.Codec != "dvb_subtitle" || dvb.Index != 3 {
t.Errorf("expected DVB bitmap track to be listed for burn-in selection, got %+v", dvb)
}
}
func TestBuildSubtitleURLs_HidesBurnInOnlyTracksByDefault(t *testing.T) {
file := &models.MediaFile{
ID: 43,
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "subrip"},
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
},
}
urls := buildSubtitleURLs("sess-native", file, nil, false)
if len(urls) != 2 {
t.Fatalf("expected text and PGS only, got %d: %+v", len(urls), urls)
}
if urls[0].Codec != "subrip" || urls[1].Codec != "hdmv_pgs_subtitle" {
t.Fatalf("unexpected default subtitle URLs: %+v", urls)
}
}
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
file := &models.MediaFile{
ID: 44,
ExternalSubtitles: []models.ExternalSubtitle{
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
},
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "pgs"},
},
}
urls := buildSubtitleURLs("sess-2", file, nil, false)
if len(urls) != 2 {
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
}
pgs := urls[1]
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup?file_id=44" {
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
}
}
func TestResolveBurnInSubtitle_RemapsAlternateVersionByStableTraits(t *testing.T) {
requested := &models.MediaFile{
ID: 1,
SubtitleTracks: []models.SubtitleTrack{
{Language: "en", Codec: "subrip", Title: "English"},
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true},
},
}
effective := &models.MediaFile{
ID: 2,
SubtitleTracks: []models.SubtitleTrack{
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true},
{Language: "en", Codec: "subrip", Title: "English"},
},
}
index, codec, ok := resolveBurnInSubtitle(requested, effective, 1)
if !ok || index != 0 || codec != "hdmv_pgs_subtitle" {
t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want (0, hdmv_pgs_subtitle, true)", index, codec, ok)
}
}
func TestResolveBurnInSubtitle_RejectsMissingAlternateTrack(t *testing.T) {
requested := &models.MediaFile{
ID: 1,
SubtitleTracks: []models.SubtitleTrack{
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese"},
},
}
effective := &models.MediaFile{ID: 2}
if index, codec, ok := resolveBurnInSubtitle(requested, effective, 0); ok || index != -1 || codec != "" {
t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want unavailable", index, codec, ok)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-121
View File
@@ -1,121 +0,0 @@
package handlers
import (
"context"
"log/slog"
"runtime/debug"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
const playbackV3ShadowSetting = "playback.protocol_v3_shadow_enabled"
func (h *PlaybackHandler) protocolV3ShadowEnabled(ctx context.Context) bool {
return h.settingFlagCachedV3(ctx, playbackV3ShadowSetting)
}
// shadowLegacyPlaybackV3 compares the production legacy route with a v3 plan
// without advertising or executing that plan. Legacy capabilities lack the
// detailed v3 fields, so exact source facts are used only as an explicitly
// labelled validation inference; this is never a production compatibility
// claim and cannot enable v3 playback.
func (h *PlaybackHandler) shadowLegacyPlaybackV3(ctx context.Context, req startPlaybackRequest, requestedFile, effectiveFile *models.MediaFile, audioIndex int, productionMethod playback.PlayMethod, productionTranscodeAudio bool, sessionID string) {
// The shadow planner runs on a bare goroutine off the production start
// path; an escaped panic here would take down the whole process for a
// telemetry-only comparison, so it must fail closed instead.
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "playback v3 shadow planner panicked",
"component", "playback", "playback_session_id", sessionID,
"panic", recovered, "stack", string(debug.Stack()))
}
}()
if !h.protocolV3ShadowEnabled(ctx) || requestedFile == nil || effectiveFile == nil {
return
}
request := legacyShadowRequestV3(req, effectiveFile, audioIndex, sessionID)
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: request,
RequestedFile: requestedFile,
EffectiveFile: effectiveFile,
AudioTrackIndex: audioIndex,
Settings: h.plannerSettingsV3(ctx),
Registry: h.transformationRegistryV3(ctx),
HLSRegistry: h.lazyHLSPlanningRegistryV3(ctx),
Now: time.Now(),
})
attrs := []any{
"component", "playback",
"playback_session_id", sessionID,
"requested_file_id", requestedFile.ID,
"effective_file_id", effectiveFile.ID,
"production_method", productionMethod,
"production_transcode_audio", productionTranscodeAudio,
"capability_basis", "legacy_source_exact_inference",
}
if result.Plan != nil {
attrs = append(attrs,
"shadow_plan_id", result.Plan.PlanID,
"shadow_delivery", result.Plan.Delivery,
"shadow_method", result.PlayMethod,
"shadow_transcode_audio", result.TranscodeAudio,
"shadow_reason", result.Plan.DecisionReason,
"route_match", result.PlayMethod == productionMethod && result.TranscodeAudio == productionTranscodeAudio,
)
} else if result.Terminal != nil {
attrs = append(attrs, "shadow_terminal", result.Terminal.Reason, "route_match", false)
}
slog.InfoContext(ctx, "playback v3 shadow decision", attrs...)
}
func legacyShadowRequestV3(req startPlaybackRequest, file *models.MediaFile, audioIndex int, sessionID string) playback.StartRequestV3 {
source := playback.SourceDescriptorFromFileV3(file, audioIndex)
hdr := legacyShadowHDRV3(req)
features := []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}
capabilities := playback.ClientCodecCapabilitiesV3{
CodecsVideo: append([]string(nil), req.CodecsVideo...),
CodecsAudio: append([]string(nil), req.CodecsAudio...),
Containers: append([]string(nil), req.Containers...),
MaxResolution: req.MaxResolution,
HDR: req.HDR,
HDRDetails: hdr,
}
if containsStringFoldV3(req.CodecsVideo, source.VideoCodec) {
capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
Codec: source.VideoCodec, Profiles: []string{source.VideoProfile}, Levels: []int{source.VideoLevel}, BitDepths: []int{source.BitDepth},
MaxWidth: source.Width, MaxHeight: source.Height, MaxFrameRate: source.FrameRate, MaxBitrateKbps: source.BitrateKbps, Hardware: true,
}}
}
var passthrough *playback.AudioPassthroughV3
if req.AudioPassthrough != nil {
passthrough = &playback.AudioPassthroughV3{PassthroughCodecs: append([]string(nil), req.AudioPassthrough.PassthroughCodecs...), SpatializerEnabled: req.AudioPassthrough.SpatializerEnabled, MaxChannels: req.AudioPassthrough.MaxChannels}
if containsStringFoldV3(req.AudioPassthrough.PassthroughCodecs, source.AudioCodec) && source.AudioChannels > 0 && source.AudioLayout != "" && source.AudioChannels <= req.AudioPassthrough.MaxChannels {
passthrough.Entries = []playback.AudioPassthroughEntryV3{{Codec: source.AudioCodec, ChannelCounts: []int{source.AudioChannels}, Layouts: []string{source.AudioLayout}}}
features = append(features, playback.FeatureLayoutPassthrough)
}
capabilities.AudioPassthrough = passthrough
}
engines := map[string]playback.EngineCapabilityV3{
string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true, EmbeddedBitmap: true, SidecarBitmap: true}},
string(playback.EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
string(playback.EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
}
return playback.StartRequestV3{
ProtocolVersion: playback.ProtocolV3, ClientFeatures: features, FileID: file.ID, ProfileID: req.ProfileID,
PlaybackAttemptID: "shadow-" + sessionID, QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
StartPosition: req.StartPosition, AudioTrackIndex: &audioIndex, Capabilities: capabilities,
ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: features, Platform: "legacy-shadow", Output: playback.OutputContextV3{HDRDetails: hdr, AudioPassthrough: passthrough}, Engines: engines},
}
}
func legacyShadowHDRV3(req startPlaybackRequest) *playback.HDRCapabilitiesV3 {
if req.HdrDetails != nil {
return &playback.HDRCapabilitiesV3{HDR10: req.HdrDetails.HDR10, HDR10Plus: req.HdrDetails.HDR10Plus, HLG: req.HdrDetails.HLG, DolbyVisionProfiles: append([]int(nil), req.HdrDetails.DolbyVisionProfiles...)}
}
if req.HDR {
return &playback.HDRCapabilitiesV3{HDR10: true}
}
return nil
}
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,7 @@ func TestHLSPlanningRegistryV3UnionsPooledNodeCapabilities(t *testing.T) {
return
}
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
}})
}))
@@ -42,7 +42,7 @@ func TestHLSPlanningRegistryV3UnionsPooledNodeCapabilities(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "video_to_h264", RecipeVersion: "1"},
{Name: "video_to_h264", RecipeVersion: "2"},
{Name: "audio_to_aac", RecipeVersion: "1"},
{Name: "server_dv7_to_hdr10", RecipeVersion: "1"},
}))
@@ -123,7 +123,7 @@ func TestRemoteTransformationsV3FailureCacheSplit(t *testing.T) {
func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
capable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
}})
}))
@@ -146,7 +146,7 @@ func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
PlanID: "plan:heterogeneous",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
@@ -165,14 +165,14 @@ func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
func TestPrepareTransportV3LocalFallbackRejectsUnavailableTransformations(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "video_to_h264", RecipeVersion: "1"},
{Name: "video_to_h264", RecipeVersion: "2"},
{Name: "audio_to_aac", RecipeVersion: "1"},
}))
plan := &playback.PlanV3{
PlanID: "plan:local-capability",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
@@ -124,10 +124,21 @@ func planProfileSettingsSync(
if field.raw == nil {
continue
}
out = append(out, profileSettingSync{
key: field.key,
value: json.RawMessage(strconv.FormatBool(*field.raw)),
})
value := json.RawMessage(strconv.FormatBool(*field.raw))
out = append(out, profileSettingSync{key: field.key, value: value})
// A key that has a replacement carries it along, so the legacy profile
// route lands the same pair of rows the canonical route does. Without
// this, a client that still sets auto_skip_intro through PUT /profiles
// would leave intro_skip_mode resolving to the contract default and a
// new client would read a preference nobody chose.
mirror, ok, err := settingscontract.MirrorWrite(field.key, value)
if err != nil {
return nil, fmt.Errorf("%s: %w", field.key, err)
}
if ok {
out = append(out, profileSettingSync{key: mirror.Key, value: mirror.Value})
}
}
return out, nil
}
@@ -79,7 +79,8 @@ func TestUpdateProfileSyncsCanonicalMetadataLanguage(t *testing.T) {
// TestUpdateProfileSyncsCanonicalAudioLanguage is the playback-start half: a
// profile that never had a backfilled row chooses a spoken language, and the
// canonical store — which handleStartPlaybackLegacy resolves — must carry it.
// canonical store — which preferredAudioTrackIndexV3 resolves when a start omits
// the audio track — must carry it.
func TestUpdateProfileSyncsCanonicalAudioLanguage(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
@@ -169,6 +170,10 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
settingskeys.PlaybackAutoSkipCredits: `true`,
settingskeys.PlaybackAutoSkipRecap: `true`,
settingskeys.PlaybackAutoPlayNextPreview: `false`,
// auto_skip_intro carries its revision-7 replacement with it, so a
// client still using the legacy route does not leave a current client
// resolving the contract default.
settingskeys.PlaybackIntroSkipMode: `"always"`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
@@ -195,6 +200,40 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
})
}
// TestUpdateProfileSyncsIntroSkipMode covers both directions of the legacy
// switch, including the one an untouched-looking false has to produce: "ask" is
// the mode that reproduces what auto_skip_intro=false always did, and a profile
// route that wrote only the boolean would leave the enum saying something else.
func TestUpdateProfileSyncsIntroSkipMode(t *testing.T) {
for _, tc := range []struct{ body, wantMode, wantBool string }{
{`{"auto_skip_intro":true}`, `"always"`, `true`},
{`{"auto_skip_intro":false}`, `"ask"`, `false`},
} {
t.Run(tc.body, func(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1", tc.body); rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackIntroSkipMode: tc.wantMode,
settingskeys.PlaybackAutoSkipIntro: tc.wantBool,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
})
}
}
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
// endpoint would refuse must fail the request as a no-op instead of leaving
// the column and the canonical store disagreeing.
+15 -12
View File
@@ -23,18 +23,19 @@ import (
// SectionHandler handles section management and batch section endpoints.
type SectionHandler struct {
repo *sections.Repository
fetcher *sections.Fetcher
previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection
episodeFetcher sectionEpisodeFetcher
FolderRepo *catalog.FolderRepository
EpisodeRepo *catalog.EpisodeRepository
StoreProvider userstore.UserStoreProvider
UserRepo *auth.UserRepository
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
EbookProgress EbookReaderProgressLister
repo *sections.Repository
fetcher *sections.Fetcher
previewFetcher sectionPreviewFetcher // set to fetcher at construction; separate for test injection
episodeFetcher sectionEpisodeFetcher
FolderRepo *catalog.FolderRepository
EpisodeRepo *catalog.EpisodeRepository
StoreProvider userstore.UserStoreProvider
UserRepo *auth.UserRepository
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner
EbookProgress EbookReaderProgressLister
}
// NewSectionHandler creates a new SectionHandler.
@@ -405,6 +406,8 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context.
}
if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
slog.WarnContext(ctx, "failed to delete unreferenced section-managed collection", "component", "api", "collection_id", collectionID, "error", err)
} else if err == nil && h.SortPreferenceCleaner != nil {
h.SortPreferenceCleaner.DeleteForCollection(ctx, userstore.CollectionKindLibrary, collectionID)
}
}
@@ -1070,3 +1070,66 @@ func withRouteParams(req *http.Request, params map[string]string) *http.Request
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
// TestLegacyDeviceSettingCarriesIntroSkipMode closes the third write path onto
// the intro-skip pair. The shipped apps save this switch through the legacy
// generic route, not through /settings/values, so without the mirror in the
// runtime plan an updated client would resolve the contract default while the
// household's own device said otherwise.
func TestLegacyDeviceSettingCarriesIntroSkipMode(t *testing.T) {
store := newProfileTestStore(t)
handler := NewSettingsHandler(testUserStoreProvider{store: store})
send := func(method string, body []byte) *httptest.ResponseRecorder {
key := "playback.auto_skip_intro"
req := httptest.NewRequest(method, "/settings/device/"+key, bytes.NewReader(body))
req = withRouteParams(req, map[string]string{"key": key})
req.Header.Set(deviceIDHeader, "living-room")
req = req.WithContext(apimw.SetProfileID(
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
rec := httptest.NewRecorder()
if method == http.MethodPut {
handler.HandleSetDeviceSetting(rec, req)
} else {
handler.HandleDeleteDeviceSetting(rec, req)
}
return rec
}
canonical := func(key string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "living-room",
})
if err != nil {
t.Fatalf("GetSettingValue(%s): %v", key, err)
}
return value
}
for _, tc := range []struct{ body, wantBool, wantMode string }{
{`{"value":"true"}`, `true`, `"always"`},
{`{"value":"false"}`, `false`, `"ask"`},
} {
if rec := send(http.MethodPut, []byte(tc.body)); rec.Code != http.StatusNoContent {
t.Fatalf("PUT %s = %d: %s", tc.body, rec.Code, rec.Body.String())
}
if value := canonical("playback.auto_skip_intro"); value == nil || string(value.Value) != tc.wantBool {
t.Fatalf("canonical auto_skip_intro after %s = %+v, want %s", tc.body, value, tc.wantBool)
}
if value := canonical("playback.intro_skip_mode"); value == nil || string(value.Value) != tc.wantMode {
t.Fatalf("canonical intro_skip_mode after %s = %+v, want %s", tc.body, value, tc.wantMode)
}
}
// Clearing through the same route has to reach both rows, or the companion
// would go on overriding at a scope the device just gave up.
if rec := send(http.MethodDelete, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
for _, key := range []string{"playback.auto_skip_intro", "playback.intro_skip_mode"} {
if value := canonical(key); value != nil {
t.Errorf("%s survived the legacy delete: %+v", key, value)
}
}
}
+163 -1
View File
@@ -813,6 +813,21 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// A deprecated key and its replacement are one preference stored twice
// while old clients are in the field, so a write to either lands on both.
// The value is already normalized, so a conversion failure here is a defect
// in the pairing rather than bad input — refuse the request rather than
// store half of it.
mirror, hasMirror, err := settingscontract.MirrorWrite(identity.Key, normalized)
if err != nil {
slog.ErrorContext(r.Context(), "settings mirror could not convert a normalized value",
"component", "api", "key", identity.Key, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
mirrorIdentity := identity
mirrorIdentity.Key = mirror.Key
// Idempotency: a client that retries a write after a dropped response must
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
@@ -821,12 +836,30 @@ func (h *SettingValuesHandler) setValueAt(
var idempotentResult json.RawMessage
if mutationID == "" {
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
if err == nil && hasMirror {
// No transaction on this path — the store's plain write API has
// none — so the companion write is a second statement. Its failure
// is the caller's failure: a request that reported success while
// leaving the pair disagreeing is the exact drift the mirror
// exists to prevent, and both writes are upserts, so the retry a
// 500 provokes is safe.
_, err = store.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value)
}
} else {
outcome, mutationErr := runIdempotentSettingMutation(
r.Context(), store, mutationID, hashMutationRequest(identity, normalized),
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
value, err := writer.UpsertSettingValue(r.Context(), identity, normalized)
return value, true, err
if err != nil || !hasMirror {
return value, true, err
}
// Same transaction as the primary write and the replay
// receipt, so a replay re-serves the receipt without
// re-applying either row.
if _, err := writer.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value); err != nil {
return nil, false, err
}
return value, true, nil
},
)
if errors.Is(mutationErr, errMutationIDConflict) {
@@ -855,6 +888,14 @@ func (h *SettingValuesHandler) setValueAt(
return
}
// GET /profiles still serves auto_skip_intro from the legacy column, so a
// profile-scope choice made through either half of the pair has to reach it
// or the profile DTO keeps reporting the preference the household abandoned.
if !h.syncLegacyIntroSkipColumn(r, w, store, identity,
legacyIntroSkipColumnWrite(identity.Key, normalized, mirror, hasMirror)) {
return
}
response := settingValueToResponse(*stored)
acting := actingProfileID(r.Context())
if identity.Scope == settingscontract.ScopeProfileDevice {
@@ -891,6 +932,104 @@ func (h *SettingValuesHandler) setValueAt(
}
}
// legacyIntroSkipColumnWrite is the playback.auto_skip_intro value a canonical
// write implies, or nil when the write is not part of the intro-skip pair.
//
// Either half answers, because the mirror has already made them one preference:
// the boolean is its own answer, and the enum's is the companion just computed
// for it. Both have to answer, or the column would be movable in one direction
// only — an enum write could set it and the boolean write that followed could
// not correct it.
func legacyIntroSkipColumnWrite(
key string,
normalized json.RawMessage,
mirror settingscontract.MirroredWrite,
hasMirror bool,
) json.RawMessage {
if !hasMirror {
return nil
}
switch key {
case settingskeys.PlaybackAutoSkipIntro:
return normalized
case settingskeys.PlaybackIntroSkipMode:
return mirror.Value
default:
return nil
}
}
// legacyIntroSkipColumnCleared is the playback.auto_skip_intro value a cleared
// profile-scope row leaves behind, or nil when the cleared key is not part of
// the intro-skip pair.
//
// Clearing the profile-scope row is how a household says "inherit again", so
// what the column must now hold is what the key resolves to with no explicit
// value: the contract default. It is read from the manifest rather than spelled
// here so a changed default cannot leave the column behind.
func (h *SettingValuesHandler) legacyIntroSkipColumnCleared(key string) json.RawMessage {
switch key {
case settingskeys.PlaybackAutoSkipIntro, settingskeys.PlaybackIntroSkipMode:
default:
return nil
}
def, ok := h.contract.Lookup(settingskeys.PlaybackAutoSkipIntro)
if !ok || len(def.DefaultValue) == 0 {
return nil
}
return def.DefaultValue
}
// syncLegacyIntroSkipColumn writes the intro-skip preference through to
// user_profiles.auto_skip_intro, and reports whether the request may continue.
// A nil value means this request has nothing to say about the column.
//
// GET /profiles still serves auto_skip_intro from its column — the profile
// DTO's shape is pinned by shipped clients — so the column is a third copy of
// one preference and has to track every canonical profile-scope change to
// either half of the pair, set or clear alike. A column that only the enum
// write could move would end up contradicting the very row the caller stored:
// an enum write sets it true, and a later boolean write, or a DELETE that goes
// back to inheriting, leaves the DTO reporting a choice nobody holds.
//
// Device scope is none of its business: the column is profile-wide, and one
// television's override is not the household's choice. The canonical write path
// touches no other legacy preference column — the cutover direction is that the
// profile DTO stops reading them, which it already has for the language and
// subtitle fields — so this stays the narrow repair for the one field still
// served from its column.
//
// A failure fails the request for the same reason the companion row write does:
// the caller asked for one preference change, and reporting success while half
// of it landed is what makes the copies drift.
func (h *SettingValuesHandler) syncLegacyIntroSkipColumn(
r *http.Request,
w http.ResponseWriter,
store userstore.UserStore,
identity userstore.SettingIdentity,
value json.RawMessage,
) bool {
if value == nil || identity.Scope != settingscontract.ScopeProfile {
return true
}
var enabled bool
if err := json.Unmarshal(value, &enabled); err != nil {
slog.ErrorContext(r.Context(), "intro skip preference produced a non-boolean auto_skip_intro",
"component", "api", "profile_id", identity.ProfileID, "key", identity.Key, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
if err := store.UpdateProfile(r.Context(), identity.ProfileID,
userstore.UpdateProfileInput{AutoSkipIntro: &enabled}); err != nil {
slog.ErrorContext(r.Context(), "failed to mirror the intro skip preference into the profile column",
"component", "api", "profile_id", identity.ProfileID, "key", identity.Key, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return false
}
return true
}
// registerWritingDevice refreshes the device registry from the request's
// device headers after a successful profile_device write. Best effort and
// throttled: the value write already succeeded, and the registry entry is
@@ -965,6 +1104,29 @@ func (h *SettingValuesHandler) deleteValueAt(
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
// Clearing one half of a mirrored pair clears both: a surviving companion
// row would go on resolving as an explicit choice at a scope the caller
// just said it wanted to inherit at. Only attempted once the primary row
// really went away, so a 404 for "nothing set here" still means nothing was
// touched. A companion that was already absent removes nothing, which is
// success.
if mirrorKey, ok := settingscontract.MirrorKey(identity.Key); ok {
mirrorIdentity := identity
mirrorIdentity.Key = mirrorKey
if _, err := store.DeleteSettingValue(r.Context(), mirrorIdentity); err != nil {
slog.ErrorContext(r.Context(), "failed to clear the mirrored setting",
"component", "api", "key", identity.Key, "mirror_key", mirrorKey, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
return
}
}
// Both rows are gone, so the profile now inherits — and the legacy column
// has to say so too, or GET /profiles would go on serving the value this
// request just cleared.
if !h.syncLegacyIntroSkipColumn(r, w, store, identity,
h.legacyIntroSkipColumnCleared(identity.Key)) {
return
}
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: "clear",
ActorProfileID: actingProfileID(r.Context()),
@@ -0,0 +1,389 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// The intro-skip pair is one preference stored under two keys for one release:
// revision 7 replaced the boolean playback.auto_skip_intro with the three-way
// playback.intro_skip_mode, and every shipped client still reads the boolean.
// These tests hold the canonical write path to keeping the two in step, because
// a household whose two keys disagree gets a different intro behavior on every
// device it owns.
// storedValueAt reads one explicit row, whatever scope it lives at.
func storedValueAt(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity,
) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), identity)
if err != nil {
t.Fatalf("reading %s at %s: %v", identity.Key, identity.Scope, err)
}
return value
}
func profileIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
}
func deviceIdentity(key string) userstore.SettingIdentity {
return userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "device-1",
}
}
// requireStored asserts a row exists and holds want.
func requireStored(
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, want string,
) {
t.Helper()
value := storedValueAt(t, store, identity)
if value == nil {
t.Fatalf("no %s row at %s", identity.Key, identity.Scope)
}
if string(value.Value) != want {
t.Errorf("%s at %s = %s, want %s", identity.Key, identity.Scope, value.Value, want)
}
}
// TestWritingTheDeprecatedBooleanMirrorsTheEnum: an unmigrated client saves the
// switch and a current client must read the mode the household actually chose,
// not the contract default.
func TestWritingTheDeprecatedBooleanMirrorsTheEnum(t *testing.T) {
for name, tc := range map[string]struct{ body, wantMode string }{
"on": {`{"value":true}`, `"always"`},
"off": {`{"value":false}`, `"ask"`},
} {
t.Run(name, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile", []byte(tc.body))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), tc.wantMode)
// The response is the key the caller addressed, unchanged: the
// mirror is a side effect, not a redirect.
var response settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decoding response: %v; body=%s", err, rec.Body.String())
}
if response.Key != settingskeys.PlaybackAutoSkipIntro {
t.Errorf("response key = %s, want the key that was written", response.Key)
}
})
}
}
// TestWritingTheEnumMirrorsTheDeprecatedBoolean covers all three modes,
// including the one the boolean cannot express.
func TestWritingTheEnumMirrorsTheDeprecatedBoolean(t *testing.T) {
for _, tc := range []struct{ mode, wantBool string }{
{"never", `false`},
{"ask", `false`},
{"always", `true`},
} {
t.Run(tc.mode, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"`+tc.mode+`"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT %q = %d: %s", tc.mode, rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode),
`"`+tc.mode+`"`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), tc.wantBool)
})
}
}
// TestTheMirrorFollowsTheIdentity, not just the key: a device override must not
// silently become a profile-wide one.
func TestTheMirrorFollowsTheIdentity(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("device PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, deviceIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
if value := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro)); value != nil {
t.Errorf("a device write left a profile-scope mirror row: %s", value.Value)
}
}
// TestDeletingEitherHalfClearsBoth. A row left behind would go on resolving as
// an explicit choice at the very scope the caller asked to inherit at.
func TestDeletingEitherHalfClearsBoth(t *testing.T) {
for _, deleted := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
t.Run(deleted, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
rec := routeValues(t, handler, http.MethodDelete, deleted, "scope=profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE %s = %d: %s", deleted, rec.Code, rec.Body.String())
}
for _, key := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
if value := storedValueAt(t, store, profileIdentity(key)); value != nil {
t.Errorf("%s survived a DELETE of %s: %s", key, deleted, value.Value)
}
}
})
}
}
// TestDeletingAnAbsentValueTouchesNeitherHalf: the 404 for "nothing set here"
// still has to mean nothing was written, mirror included.
func TestDeletingAnAbsentValueTouchesNeitherHalf(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile",
[]byte(`{"value":true}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
// Nothing was ever written at device scope, so this clears nothing —
// including the profile-scope pair, which belongs to another identity.
rec := routeValues(t, handler, http.MethodDelete,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("DELETE of an unset device value = %d, want 404: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"always"`)
}
// TestMirroredWritesReplayInsteadOfDoubleApplying. The mirror rides inside the
// mutation's transaction, so a retried write must re-serve the receipt and
// leave both rows at the revision the first write produced.
func TestMirroredWritesReplayInsteadOfDoubleApplying(t *testing.T) {
handler, store := newValuesTestHandler(t)
send := func(mutationID, value string) *httptest.ResponseRecorder {
req := valuesRequest(http.MethodPut,
"/settings/values/"+settingskeys.PlaybackIntroSkipMode+"?scope=profile",
[]byte(`{"value":`+value+`}`))
req.Header.Set(mutationIDHeader, mutationID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", settingskeys.PlaybackIntroSkipMode)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
return rec
}
if rec := send("mut-intro", `"always"`); rec.Code != http.StatusOK {
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
}
mirrored := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if mirrored == nil {
t.Fatal("the first write stored no mirror row")
}
replay := send("mut-intro", `"always"`)
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("replay = %d header %q: %s",
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
}
after := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
if after == nil {
t.Fatal("the replay removed the mirror row")
}
if after.Revision != mirrored.Revision {
t.Errorf("mirror revision moved from %d to %d on replay; the replay wrote again",
mirrored.Revision, after.Revision)
}
if string(after.Value) != `true` {
t.Errorf("mirror value = %s after replay, want true", after.Value)
}
}
// TestProfileScopeEnumWriteUpdatesTheLegacyColumn. GET /profiles still serves
// auto_skip_intro from user_profiles, so a household that switches intros off
// through the new key must not keep seeing the old answer in the profile DTO.
func TestProfileScopeEnumWriteUpdatesTheLegacyColumn(t *testing.T) {
handler, store := newValuesTestHandler(t)
if err := store.UpdateProfile(context.Background(), "profile-1",
userstore.UpdateProfileInput{AutoSkipIntro: boolPtr(true)}); err != nil {
t.Fatalf("seeding the column: %v", err)
}
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile", []byte(`{"value":"never"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still true after intro_skip_mode was set to never")
}
// And back the other way, so the column tracks the mode rather than only
// ever being cleared.
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("second PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err = store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("re-reading profile: %v", err)
}
if !profile.AutoSkipIntro {
t.Error("user_profiles.auto_skip_intro still false after intro_skip_mode was set to always")
}
}
// requireColumn asserts what user_profiles.auto_skip_intro holds, which is
// still what GET /profiles serves and what the web player reads.
func requireColumn(t *testing.T, store userstore.UserStore, want bool, when string) {
t.Helper()
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro != want {
t.Errorf("user_profiles.auto_skip_intro = %v %s, want %v",
profile.AutoSkipIntro, when, want)
}
}
// TestABooleanWriteCorrectsAColumnTheEnumMoved. The column has to be movable
// from both halves of the pair, or the first enum write pins it: an old client
// that then saves the switch stores its value in the canonical rows and reads
// the opposite back out of the profile DTO.
func TestABooleanWriteCorrectsAColumnTheEnumMoved(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("enum PUT = %d: %s", rec.Code, rec.Body.String())
}
requireColumn(t, store, true, "after intro_skip_mode was set to always")
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackAutoSkipIntro, "scope=profile",
[]byte(`{"value":false}`)); rec.Code != http.StatusOK {
t.Fatalf("boolean PUT = %d: %s", rec.Code, rec.Body.String())
}
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), `false`)
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"ask"`)
requireColumn(t, store, false, "after the switch was turned off")
}
// TestClearingTheProfileScopePairResetsTheLegacyColumn. A cleared row means
// "inherit again", so the column has to fall back with it — otherwise the
// profile DTO, and the web player that reads it, go on acting on the choice
// this request removed.
func TestClearingTheProfileScopePairResetsTheLegacyColumn(t *testing.T) {
for _, cleared := range []string{
settingskeys.PlaybackIntroSkipMode,
settingskeys.PlaybackAutoSkipIntro,
} {
t.Run(cleared, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile",
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
}
requireColumn(t, store, true, "after the seeding write")
if rec := routeValues(t, handler, http.MethodDelete,
cleared, "scope=profile", nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE %s = %d: %s", cleared, rec.Code, rec.Body.String())
}
// Nothing is stored at any scope now, so the pair resolves to the
// contract default — "ask", which the boolean spells false.
requireColumn(t, store, false, "after the profile-scope value was cleared")
})
}
}
// TestClearingADeviceOverrideLeavesTheProfileColumnAlone: the column follows the
// profile-wide choice, and giving up one television's override does not revoke
// it.
func TestClearingADeviceOverrideLeavesTheProfileColumnAlone(t *testing.T) {
handler, store := newValuesTestHandler(t)
for _, scope := range []string{"scope=profile", "scope=profile_device"} {
if rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, scope,
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("seed PUT at %s = %d: %s", scope, rec.Code, rec.Body.String())
}
}
requireColumn(t, store, true, "after the profile-scope write")
if rec := routeValues(t, handler, http.MethodDelete,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", nil); rec.Code != http.StatusNoContent {
t.Fatalf("device DELETE = %d: %s", rec.Code, rec.Body.String())
}
requireColumn(t, store, true, "after a device override was cleared")
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"always"`)
}
// TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone: the column is
// profile-wide, and one television's override is not the household's choice.
func TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone(t *testing.T) {
handler, store := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut,
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.AutoSkipIntro {
t.Error("a device-scope write moved the profile-wide column")
}
}
+98 -11
View File
@@ -20,6 +20,12 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles"
)
const (
subtitleFormatASS = "ass"
subtitleFormatSSA = "ssa"
subtitleFormatSUP = "sup"
)
// FilePathResolver looks up a media file by its ID.
type FilePathResolver interface {
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
@@ -159,7 +165,18 @@ func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) {
seekSeconds = s
}
}
if err := playback.ServeRemuxWithDVMode(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), session.RemuxDVMode, h.ffmpegPath()); err != nil {
// An audio-only source muxes an audio-only fMP4. The v3 plan promises
// audio/mp4 for it, and a declared-tier client refuses to attach a
// source buffer whose advertised type its probe rejected — so the
// response has to keep the same promise the plan made.
if err := playback.ServeRemuxWithOptions(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), playback.RemuxServeOptions{
DVMode: session.RemuxDVMode,
FFmpegPath: h.ffmpegPath(),
ContentType: playback.RemuxContentType(file.IsAudioOnly()),
AudioOnly: file.IsAudioOnly(),
TargetAudioChannels: session.TargetAudioChannels,
TargetAudioBitrateKbps: session.TargetAudioBitrateKbps,
}); err != nil {
h.handleTransportStartFailure(r.Context(), session, file, err)
}
@@ -219,10 +236,16 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
return
}
// New subtitle artifact URLs bind downloaded subtitles by their stable row
// trackIndex is a combined ordinal, resolved through the same three
// consecutive ranges playback.BuildSubtitleInventoryV3 assigns them from:
// externals, then embedded container tracks, then downloaded ones. The
// ranges cover the full track arrays — including bitmap tracks that have no
// sidecar shape — so an ordinal always names the same track here as it does
// in the published inventory.
// Downloaded subtitle URLs additionally bind that ordinal to a stable row
// identity. The path ordinal remains for compatibility and display, but it
// must not be re-resolved against a mutable inventory after a seek reanchor.
if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" {
if rawID := strings.TrimSpace(r.URL.Query().Get(playback.DownloadedSubtitleIDParamV3)); rawID != "" {
downloadedID, parseErr := strconv.Atoi(rawID)
if parseErr != nil || downloadedID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
@@ -246,13 +269,25 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
return
}
if r.Method == http.MethodHead {
writeSubtitleRepresentationHead(w, requestedFormat)
return
}
h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat)
return
}
externalCount := len(file.ExternalSubtitles)
if trackIndex < externalCount {
sub := file.ExternalSubtitles[trackIndex]
if !subtitleSidecarFormatSupported(sub.Format, requestedFormat, false) {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
"Requested subtitle extension does not match the selected track")
return
}
if r.Method == http.MethodHead {
writeSubtitleRepresentationHead(w, requestedFormat)
return
}
// Serve ASS/SSA external subtitles as raw data for client-side rendering.
if playback.IsASS(sub.Format) && requestedFormat != "vtt" {
@@ -262,7 +297,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
"Failed to load external subtitle")
return
}
playback.ServeSubtitle(w, data, "ass")
playback.ServeSubtitle(w, data, subtitleFormatASS)
return
}
@@ -289,6 +324,15 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
"Bitmap subtitle tracks cannot be extracted as text")
return
}
if !subtitleSidecarFormatSupported(track.Codec, requestedFormat, true) {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
"Requested subtitle extension does not match the selected track")
return
}
if r.Method == http.MethodHead && requestedFormat != subtitleFormatSUP {
writeSubtitleRepresentationHead(w, requestedFormat)
return
}
// Dedicated streaming extract — ffmpeg seeks to the current
// playback position and pipes cues to the response as they're
@@ -318,6 +362,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
if r.Method == http.MethodHead {
writeSubtitleRepresentationHead(w, requestedFormat)
return
}
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
return
}
@@ -327,6 +375,11 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
}
func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) {
if !subtitleSidecarFormatSupported(string(subtitle.Format), requestedFormat, false) {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
"Requested subtitle extension does not match the selected track")
return
}
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key)
if err != nil {
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
@@ -335,7 +388,7 @@ func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.R
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
playback.ServeSubtitle(w, data, subtitleFormatASS)
return
}
@@ -354,6 +407,40 @@ func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.R
playback.ServeSubtitle(w, vttData, "vtt")
}
// subtitleSidecarFormatSupported keeps bitmap and styled-text requests within
// the representations the server can produce. Plain text tracks preserve the
// v1 endpoint's permissive extension behavior and are always returned as VTT;
// ASS/SSA may also be served losslessly, and only an embedded PGS track has a
// binary .sup representation.
func subtitleSidecarFormatSupported(codec, requestedFormat string, embeddedPGS bool) bool {
requestedFormat = strings.ToLower(strings.TrimSpace(requestedFormat))
if requestedFormat == "" {
return true
}
if playback.IsPGS(codec) {
return embeddedPGS && requestedFormat == subtitleFormatSUP
}
if playback.NeedsBurnIn(codec) {
return false
}
if playback.IsASS(codec) {
return requestedFormat == subtitleFormatASS || requestedFormat == subtitleFormatSSA || requestedFormat == "vtt"
}
return true
}
func writeSubtitleRepresentationHead(w http.ResponseWriter, requestedFormat string) {
switch strings.ToLower(strings.TrimSpace(requestedFormat)) {
case subtitleFormatASS, subtitleFormatSSA:
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
case subtitleFormatSUP:
w.Header().Set("Content-Type", "application/octet-stream")
default:
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
}
w.WriteHeader(http.StatusOK)
}
// subtitleSourceFileID pins a subtitle URL to the file whose track list was
// used to create it. A quality/seek restart may change session.MediaFileID to
// an alternate version; interpreting the old combined track index against the
@@ -527,9 +614,9 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
outFormat := "vtt"
switch {
case playback.IsASS(track.Codec):
outFormat = "ass"
outFormat = subtitleFormatASS
case playback.IsPGS(track.Codec):
outFormat = "sup"
outFormat = subtitleFormatSUP
}
// ASS is fetched exactly once and consumed whole by its client-side
@@ -549,7 +636,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
case "vtt":
seek = subtitleSeekPosition(r, session)
duration = subtitleWindowDuration(r)
case "sup":
case subtitleFormatSUP:
allowWindow, seek, duration = playback.PGSWindowRequest(r.URL.Query())
}
slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api",
@@ -594,14 +681,14 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
// cached full track when present (warming it in the background when
// not). All other formats stream uncached: VTT is already windowed
// and fast, ASS is small.
if outFormat == "sup" {
if outFormat == subtitleFormatSUP {
err := h.SubtitleCache.ServeSUPExtract(w, r, opts, playback.StreamExtractSubtitle)
playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex)
return
}
switch outFormat {
case "ass":
case subtitleFormatASS:
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
default:
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
+233
View File
@@ -7,12 +7,14 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
@@ -199,6 +201,77 @@ func TestHandleStream_KeepsSessionWhenLookupFailsForNonMissingReason(t *testing.
}
}
// A v3 plan for an audio-only source promises audio/mp4, because a
// declared-tier client probes the advertised MIME with isTypeSupported before
// it will attach a source buffer and "video/mp4" for a stream carrying no video
// track is exactly the mismatch that makes that probe lie. The remux response
// has to keep the promise the plan made.
func TestHandleStream_AudioOnlyRemuxServesAudioContentType(t *testing.T) {
for _, tc := range []struct {
name string
file *models.MediaFile
wantContent string
}{
{
name: "audio only source",
file: &models.MediaFile{
ID: 42,
ContentID: "audiobook-1",
BaseType: "audiobook",
CodecAudio: "flac",
Duration: 39600,
},
wantContent: playback.AudioOnlyRemuxMIMEV3,
},
{
name: "video source",
file: &models.MediaFile{
ID: 42,
ContentID: "movie-1",
CodecVideo: "h264",
CodecAudio: "flac",
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
Duration: 3600,
},
wantContent: "video/mp4",
},
} {
t.Run(tc.name, func(t *testing.T) {
file := *tc.file
file.FilePath = writePlaybackTestMediaFile(t, "source.mkv")
sessionMgr := playback.NewSessionManager(0, 0)
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
ffmpeg := filepath.Join(t.TempDir(), "ffmpeg")
if err := os.WriteFile(ffmpeg, []byte("#!/bin/sh\nprintf muxed\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
handler := NewStreamHandler(sessionMgr, testPlaybackFileResolver{file: &file})
handler.PlaybackConfig = func() config.PlaybackConfig {
return config.PlaybackConfig{FFmpegPath: ffmpeg}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID, nil)
req = req.WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", session.ID)
rr := httptest.NewRecorder()
handler.HandleStream(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); got != tc.wantContent {
t.Fatalf("Content-Type = %q, want %q", got, tc.wantContent)
}
})
}
}
// TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500 pins the fix for
// issue #248: a failure listing downloaded subtitles must surface as a 500 with
// an "internal_error" code, not be swallowed and reported to the client as a
@@ -363,6 +436,166 @@ func TestHandleSubtitle_BitmapTrackVTTRequestReturns415(t *testing.T) {
}
}
func TestHandleSubtitle_ExternalTextTrackSRTRequestReturnsVTT(t *testing.T) {
subtitlePath := filepath.Join(t.TempDir(), "movie.en.srt")
if err := os.WriteFile(subtitlePath, []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"), 0o644); err != nil {
t.Fatal(err)
}
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Duration: 3600,
ExternalSubtitles: []models.ExternalSubtitle{
{Path: subtitlePath, Language: "eng", Format: "srt"},
},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.srt", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.srt")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, content-type = %q, body = %s; want 200", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/vtt") {
t.Fatalf("content type = %q, want WebVTT", got)
}
if body := rr.Body.String(); !strings.HasPrefix(body, "WEBVTT") || !strings.Contains(body, "Hello") {
t.Fatalf("body = %q, want converted WebVTT", body)
}
}
func TestHandleSubtitle_ExternalASSTrackASSRequestReturnsRawASS(t *testing.T) {
const assBody = "[Script Info]\nTitle: Test\n"
subtitlePath := filepath.Join(t.TempDir(), "movie.en.ass")
if err := os.WriteFile(subtitlePath, []byte(assBody), 0o644); err != nil {
t.Fatal(err)
}
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Duration: 3600,
ExternalSubtitles: []models.ExternalSubtitle{{Path: subtitlePath, Language: "eng", Format: "ass"}},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.ass", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.ass")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusOK || rr.Body.String() != assBody {
t.Fatalf("status = %d, content-type = %q, body = %q; want raw ASS", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/x-ssa") {
t.Fatalf("content type = %q, want raw ASS", got)
}
}
func TestHandleSubtitle_ExternalTextHEADDoesNotLoadTheArtifact(t *testing.T) {
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/missing/movie.mkv",
Duration: 3600,
ExternalSubtitles: []models.ExternalSubtitle{{Path: "/missing/movie.en.srt", Language: "eng", Format: "srt"}},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", file.ID, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
req := httptest.NewRequest(http.MethodHead, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.vtt")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusOK || rr.Body.Len() != 0 || !strings.HasPrefix(rr.Header().Get("Content-Type"), "text/vtt") {
t.Fatalf("HEAD status=%d type=%q body=%q", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
}
}
func TestHandleSubtitle_EmbeddedPGSSupportsCachedHEADAndRange(t *testing.T) {
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
Duration: 3600,
// The external track deliberately occupies combined ordinal 0. The PGS
// container track is therefore addressed as ordinal 1, even though its
// embedded subtitle-stream index is 0.
ExternalSubtitles: []models.ExternalSubtitle{{Path: "/tmp/movie.en.srt", Format: "srt"}},
SubtitleTracks: []models.SubtitleTrack{{Index: 2, Language: "eng", Codec: "hdmv_pgs_subtitle"}},
}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", file.ID, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
cacheRoot := t.TempDir()
cache := playback.NewSubtitleCache(func() string { return cacheRoot })
warmReq := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
warmRR := httptest.NewRecorder()
if err := cache.ServeSUPExtract(warmRR, warmReq, playback.StreamExtractOpts{
InputPath: file.FilePath, TrackIndex: 0, SourceCodec: "hdmv_pgs_subtitle",
}, func(_ context.Context, opts playback.StreamExtractOpts) error {
_, err := opts.Writer.Write([]byte("SUP PAYLOAD"))
return err
}); err != nil {
t.Fatalf("warm PGS cache: %v", err)
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
handler.SubtitleCache = cache
request := func(method, rangeHeader string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "/api/v1/stream/"+session.ID+"/subtitles/1.sup?file_id=42", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "1.sup")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
return rr
}
head := request(http.MethodHead, "")
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Type") != "application/octet-stream" || head.Header().Get("Content-Length") != "11" {
t.Fatalf("HEAD status=%d type=%q length=%q body=%q", head.Code, head.Header().Get("Content-Type"), head.Header().Get("Content-Length"), head.Body.String())
}
ranged := request(http.MethodGet, "bytes=4-10")
if ranged.Code != http.StatusPartialContent || ranged.Body.String() != "PAYLOAD" || ranged.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatalf("Range status=%d type=%q body=%q", ranged.Code, ranged.Header().Get("Content-Type"), ranged.Body.String())
}
}
func TestSubtitleSourceFileIDPinsURLAcrossEffectiveFileSwitch(t *testing.T) {
session := &playback.Session{MediaFileID: 200, RequestedMediaFileID: 100}
@@ -0,0 +1,54 @@
package handlers
import (
"context"
"errors"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
// SubtitleInventoryResolver supplies the playback package with the two
// repositories it needs to rebuild a file's combined-ordinal subtitle
// inventory outside a request: the media file itself and the downloaded
// subtitle rows that occupy the tail of the ordinal space.
//
// It exists so realtime subtitle events can publish the ordinal a newly
// generated track will hold in the next plan, instead of leaving each client to
// reconstruct one by counting the tracks it can see.
type SubtitleInventoryResolver struct {
files FilePathResolver
subtitles subtitles.Repository
}
// NewSubtitleInventoryResolver returns a resolver, or nil when no file lookup
// is available. A nil subtitle repository is fine: the inventory then covers
// only the file's external and embedded tracks.
func NewSubtitleInventoryResolver(files FilePathResolver, repo subtitles.Repository) *SubtitleInventoryResolver {
if files == nil {
return nil
}
return &SubtitleInventoryResolver{files: files, subtitles: repo}
}
// MediaFile loads the file whose subtitle inventory is being resolved.
func (r *SubtitleInventoryResolver) MediaFile(ctx context.Context, fileID int) (*models.MediaFile, error) {
if r == nil || r.files == nil {
return nil, errors.New("subtitle inventory resolver has no media file lookup")
}
return r.files.GetByID(ctx, fileID)
}
// AdditionalSubtitles returns the downloaded and generated tracks that follow
// the file's external and embedded tracks in the combined ordinal space.
func (r *SubtitleInventoryResolver) AdditionalSubtitles(ctx context.Context, file *models.MediaFile) ([]playback.SubtitleInventoryEntryV3, error) {
if r == nil || r.subtitles == nil || file == nil {
return nil, nil
}
downloaded, err := r.subtitles.ListDownloadedSubtitles(ctx, file.ID)
if err != nil {
return nil, err
}
return downloadedSubtitleEntriesV3(file, downloaded), nil
}
+56
View File
@@ -98,6 +98,62 @@ func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *htt
return authorizeMediaFileAccess(w, r, h.FileAuthorizer, fileID)
}
// subtitleProviderStatusResponse tells a client whether this deployment can
// search external subtitle providers at all, following the per-subsystem
// capability convention (/subtitles/ai/status, /items/trailers/capability).
//
// Without it a search on a server with no providers configured answers exactly
// like a search that ran and matched nothing, so a player has no way to tell
// "this server cannot do that" from "nothing found for this file" and ends up
// offering an entry point that can never succeed. A client that finds enabled
// false should disable the search action and say why rather than let the user
// run a query that is guaranteed to come back empty.
//
// Provider names are safe to return to any authenticated viewer: they already
// travel in every SubtitleResult.provider and DownloadedSubtitle.provider. The
// credentials behind them stay in the admin-only provider config.
type subtitleProviderStatusResponse struct {
SchemaVersion int `json:"schema_version"`
// Enabled reports that at least one provider is registered, so
// POST /subtitles/search can actually reach an upstream.
Enabled bool `json:"enabled"`
// Providers is the registered provider identifiers, sorted. Always a
// list — empty rather than null — so clients can iterate it unguarded.
Providers []string `json:"providers"`
}
// HandleProviderStatus reports whether external subtitle search is available
// here, so the player can show or hide the entry point.
// GET /api/v1/subtitles/providers/status
//
// It answers even when the subtitle subsystem is unwired, because enabled:false
// is the answer in that case; the router registers a fallback so a client never
// has to interpret a 404 on the probe itself.
func (h *SubtitleSearchHandler) HandleProviderStatus(w http.ResponseWriter, _ *http.Request) {
providers := []string{}
if h != nil && h.manager != nil {
providers = h.manager.ProviderNames()
}
writeJSON(w, http.StatusOK, subtitleProviderStatusResponse{
SchemaVersion: 1,
Enabled: len(providers) > 0,
Providers: providers,
})
}
// WriteSubtitleProvidersDisabledStatus answers the subtitle provider capability
// probe with a 200 {"enabled": false, "providers": []} when no subtitle handler
// is wired, so the client gets a clean negative instead of a 404 (the 2-segment
// /providers/status path is not shadowed by the 1-segment /{media_file_id}
// route — they never compete in chi's router).
func WriteSubtitleProvidersDisabledStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, subtitleProviderStatusResponse{
SchemaVersion: 1,
Enabled: false,
Providers: []string{},
})
}
// HandleSearch handles POST /api/v1/subtitles/search
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
var req searchSubtitlesRequest
@@ -235,6 +235,109 @@ func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
}
}
// stubSubtitleProvider is a registerable no-op provider; only its name matters
// to the capability probe.
type stubSubtitleProvider struct {
name string
}
func (s stubSubtitleProvider) Name() string { return s.name }
func (s stubSubtitleProvider) Search(context.Context, subtitles.SearchRequest) ([]subtitles.SubtitleResult, error) {
return nil, nil
}
func (s stubSubtitleProvider) Download(context.Context, string) ([]byte, subtitles.SubtitleFormat, error) {
return nil, subtitles.FormatSRT, nil
}
func decodeProviderStatus(t *testing.T, rr *httptest.ResponseRecorder) struct {
SchemaVersion int `json:"schema_version"`
Enabled bool `json:"enabled"`
Providers []string `json:"providers"`
} {
t.Helper()
var resp struct {
SchemaVersion int `json:"schema_version"`
Enabled bool `json:"enabled"`
Providers []string `json:"providers"`
}
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SchemaVersion != 1 {
t.Fatalf("schema_version = %d, want 1", resp.SchemaVersion)
}
return resp
}
func TestHandleProviderStatusWithoutProviders(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
rr := httptest.NewRecorder()
handler.HandleProviderStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if resp.Enabled {
t.Fatal("enabled = true, want false with no providers registered")
}
if len(resp.Providers) != 0 {
t.Fatalf("providers = %v, want empty", resp.Providers)
}
// The empty list must reach the wire as [] — a null would read to a
// client as a missing field rather than "no providers here".
if !bytes.Contains(rr.Body.Bytes(), []byte(`"providers":[]`)) {
t.Fatalf("body = %s, want providers serialized as []", rr.Body.String())
}
}
func TestHandleProviderStatusWithProviders(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
manager.RegisterProvider(stubSubtitleProvider{name: "subdl"})
manager.RegisterProvider(stubSubtitleProvider{name: "opensubtitles"})
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
rr := httptest.NewRecorder()
handler.HandleProviderStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if !resp.Enabled {
t.Fatal("enabled = false, want true with providers registered")
}
want := []string{"opensubtitles", "subdl"}
if len(resp.Providers) != len(want) {
t.Fatalf("providers = %v, want %v", resp.Providers, want)
}
for i, name := range want {
if resp.Providers[i] != name {
t.Fatalf("providers = %v, want %v (sorted)", resp.Providers, want)
}
}
}
func TestWriteSubtitleProvidersDisabledStatus(t *testing.T) {
rr := httptest.NewRecorder()
WriteSubtitleProvidersDisabledStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if resp.Enabled {
t.Fatal("enabled = true, want false from the disabled fallback")
}
if len(resp.Providers) != 0 {
t.Fatalf("providers = %v, want empty", resp.Providers)
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`"providers":[]`)) {
t.Fatalf("body = %s, want providers serialized as []", rr.Body.String())
}
}
type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
@@ -75,6 +75,7 @@ type userImportSharedFields struct {
PosterURL string `json:"poster_url"`
LibraryIDs []int `json:"library_ids,omitempty"`
DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty"`
SortConfig json.RawMessage `json:"sort_config,omitempty"`
}
type userImportMDBListRequest struct {
@@ -217,6 +218,11 @@ func (h *UserCollectionImportHandler) createImportedCollection(
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
sortConfig, err := NormalizeCollectionSortConfig(shared.SortConfig, true)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
@@ -225,7 +231,7 @@ func (h *UserCollectionImportHandler) createImportedCollection(
CollectionType: collectionType,
IsShared: shared.IsShared,
QueryDefinition: "{}",
SortConfig: "{}",
SortConfig: sortConfig,
SourceURL: cfg.DisplayURL(),
SourceConfig: sourceConfigJSON,
SyncSchedule: schedule,
+57 -3
View File
@@ -1037,7 +1037,22 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.MarkerUpserter = deps.FileRepo
}
playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub)
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub)
// A resolver lets subtitle realtime events carry the combined ordinal
// the new track will hold in the next plan. Without a file repository
// the notifier still fires; its events just omit the track block.
var subtitleInventoryResolver playback.SubtitleInventoryResolver
if deps.FileRepo != nil {
// subtitleRepo is a concrete pointer: pass it only when non-nil so
// the resolver holds a nil interface rather than a typed nil.
var subtitleReader subtitles.Repository
if subtitleRepo != nil {
subtitleReader = subtitleRepo
}
if resolver := handlers.NewSubtitleInventoryResolver(deps.FileRepo, subtitleReader); resolver != nil {
subtitleInventoryResolver = resolver
}
}
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub, subtitleInventoryResolver)
adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler)
if deps.DB != nil && deps.FileRepo != nil && viewerResolver != nil && deps.Config != nil && detailSvc != nil {
@@ -1423,6 +1438,14 @@ func NewRouter(deps Dependencies) chi.Router {
}
libraryCollectionRepo := catalog.NewLibraryCollectionRepository(deps.DB)
var collectionSortCleaner *userstore.CollectionSortPreferenceCleaner
if userRepo != nil && deps.UserStoreProvider != nil {
collectionSortCleaner = userstore.NewCollectionSortPreferenceCleaner(userRepo, deps.UserStoreProvider)
}
if collectionHandler != nil {
collectionHandler.LibraryCollections = libraryCollectionRepo
}
sectionHandler.SortPreferenceCleaner = collectionSortCleaner
if libraryCollectionService == nil {
libraryCollectionService = catalog.NewLibraryCollectionService(
libraryCollectionRepo,
@@ -1522,6 +1545,7 @@ func NewRouter(deps Dependencies) chi.Router {
libraryCollectionHandler.SectionRepo = sectionRepo
libraryCollectionHandler.UserCollectionPool = deps.DB
libraryCollectionHandler.EventsHub = deps.EventsHub
libraryCollectionHandler.SortPreferenceCleaner = collectionSortCleaner
if deps.FolderRepo != nil {
libraryCollectionHandler.FolderRepo = deps.FolderRepo
} else {
@@ -1656,6 +1680,15 @@ func NewRouter(deps Dependencies) chi.Router {
// background worker.
downloadSvc.SetSubscriptions(downloads.NewSubscriptionRepository(deps.DB))
downloadHandler = handlers.NewDownloadHandler(downloadSvc)
if deps.NodePlanner != nil {
downloadHandler.SetProxyDelivery(deps.NodePlanner, func() string {
cfg := deps.CurrentConfig()
if cfg == nil {
return ""
}
return cfg.Auth.JWTSecret
})
}
if profileHandler != nil {
// Profiles may live outside Postgres (sqlite userdb backend), so
// deleting one cannot FK-cascade the shared user_devices table;
@@ -2267,6 +2300,8 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(apimw.RequireProfile)
r.Get("/", collectionHandler.HandleListCollections)
r.Get("/capabilities", collectionHandler.HandleCapabilities)
r.Put("/sort-preference", collectionHandler.HandleSetCollectionSortPreference)
r.Delete("/sort-preference", collectionHandler.HandleClearCollectionSortPreference)
if libraryCollectionHandler != nil {
// Aggregated server (admin-curated) collections across
// every accessible library. Separate from "/" (personal,
@@ -2551,6 +2586,11 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
r.Route("/subtitles", func(r chi.Router) {
// Capability probe for external subtitle search. Two
// segments on purpose: a bare /providers would shadow
// the /{media_file_id} route below, while
// /providers/status never competes with it in chi.
r.Get("/providers/status", subtitleSearchHandler.HandleProviderStatus)
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
r.Post("/upload", subtitleSearchHandler.HandleUpload)
@@ -2571,6 +2611,17 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
})
} else {
// The whole group above is conditional (it needs the DB,
// S3 and the subtitle repo), so on a storage-less
// deployment the capability probe would 404 — leaving a
// client to interpret the same ambiguous status the probe
// exists to replace. Mount the probe alone, answering
// enabled:false, so feature detection always gets a real
// answer.
r.Route("/subtitles", func(r chi.Router) {
r.Get("/providers/status", handlers.WriteSubtitleProvidersDisabledStatus)
})
}
// Playback routes.
@@ -2599,9 +2650,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/{session_id}/replan", playbackHandler.HandleReplanPlaybackV3)
r.Post("/route-events", playbackHandler.HandlePlaybackRouteEventV3)
r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress)
r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack)
r.Delete("/{session_id}", playbackHandler.HandleStopPlayback)
r.Post("/transcode/start", playbackHandler.HandleStartTranscode)
})
})
}
@@ -2632,6 +2681,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/stream/{session_id}", streamHandler.HandleStream)
r.Head("/stream/{session_id}", streamHandler.HandleStream)
r.Get("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle)
r.Head("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle)
r.Get("/stream/{session_id}/subtitles/{track}/fonts", streamHandler.HandleSubtitleFonts)
}
@@ -2658,12 +2708,16 @@ func NewRouter(deps Dependencies) chi.Router {
// HEAD natively.
r.Get("/{id}/file", downloadHandler.HandleDownloadFile)
r.Head("/{id}/file", downloadHandler.HandleDownloadFile)
r.Get("/{id}/file-proxy", downloadHandler.HandleDownloadFileViaProxy)
r.Head("/{id}/file-proxy", downloadHandler.HandleDownloadFileViaProxy)
r.Get("/{id}/manifest", downloadHandler.HandleManifest)
r.Get("/{id}/artwork/{kind}", downloadHandler.HandleArtwork)
r.Get("/{id}/subtitles/{ref}", downloadHandler.HandleSubtitle)
})
r.Get("/direct-download", downloadHandler.HandleDirectDownload)
r.Head("/direct-download", downloadHandler.HandleDirectDownload)
r.Get("/direct-download-proxy", downloadHandler.HandleDirectDownloadViaProxy)
r.Head("/direct-download-proxy", downloadHandler.HandleDirectDownloadViaProxy)
// Recipe gallery catalog (no profile required — purely static metadata).
recipeHandler := &handlers.RecipeHandler{}
+28
View File
@@ -8,6 +8,8 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
const LibraryCollectionVisibilityVisible = "visible"
// AccessFilter captures effective viewer access constraints for catalog reads.
type AccessFilter struct {
AllowedLibraryIDs []int
@@ -30,6 +32,9 @@ type AccessFilter struct {
SelectedFileID int
UserID int
ProfileID string
// DeviceID identifies the requesting client for device-scoped setting
// resolution. It does not participate in catalog access control.
DeviceID string
// NamePrefix, when non-empty, restricts results to items whose
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
@@ -42,6 +47,29 @@ type AccessFilter struct {
ExcludedMediaTypes []string
}
// CanAccessLibraryCollection reports whether a visible server collection is
// reachable through at least one library in the viewer's effective scope.
// Collections without explicit library scope retain their legacy unrestricted
// visibility, but restricted viewers cannot address them by ID.
func CanAccessLibraryCollection(collection *models.LibraryCollection, filter AccessFilter) bool {
if collection == nil || collection.Visibility != LibraryCollectionVisibilityVisible {
return false
}
if len(collection.LibraryIDs) == 0 {
return filter.AllowedLibraryIDs == nil && len(filter.DisabledLibraryIDs) == 0
}
for _, libraryID := range collection.LibraryIDs {
if filter.AllowedLibraryIDs != nil && !intInSlice(libraryID, filter.AllowedLibraryIDs) {
continue
}
if intInSlice(libraryID, filter.DisabledLibraryIDs) {
continue
}
return true
}
return false
}
func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
if filter.MaxContentRating != "" {
allowedRatings := access.AllowedRatingsUpTo(filter.MaxContentRating)
+74 -5
View File
@@ -37,6 +37,12 @@ type CatalogResult struct {
SemanticUsed bool
FallbackReason string
IndexPendingEvents int
// EffectiveSort is the order collection sources actually resolved in, after
// applying the request's own sort, the viewer's saved override, and the
// collection's configured default in that order. An empty Field means the
// collection's source order. Only collection sources populate it; clients
// use it to show which sort is active when the request carried none.
EffectiveSort QuerySort
}
type CatalogFiltersResult struct {
@@ -487,10 +493,25 @@ func (r *CatalogResolver) resolveSectionBrowseSource(ctx context.Context, req Ca
func (r *CatalogResolver) resolveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != "visible" {
if err != nil || !CanAccessLibraryCollection(collection, access) {
return nil, ErrCatalogSourceNotFound
}
return r.resolveCollectionWithEffectiveSort(
ctx, req, access, userstore.CollectionKindLibrary, collection.ID, collection.SortConfig,
func(req CatalogRequest) (*CatalogResult, error) {
return r.resolveLibraryCollectionItems(ctx, req, access, collection, collectionRepo)
},
)
}
func (r *CatalogResolver) resolveLibraryCollectionItems(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
collection *models.LibraryCollection,
collectionRepo *LibraryCollectionRepository,
) (*CatalogResult, error) {
if IsLiveQueryType(collection.CollectionType) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
@@ -544,10 +565,55 @@ func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req C
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
return r.resolveCollectionWithEffectiveSort(
ctx, req, access, userstore.CollectionKindUser, collection.ID, []byte(collection.SortConfig),
func(req CatalogRequest) (*CatalogResult, error) {
return r.resolveUserCollectionItems(ctx, req, access, store, collection)
},
)
}
// resolveCollectionWithEffectiveSort applies the shared collection-sort
// precedence, delegates item resolution, and stamps the resolved order on the
// response. Keeping this sequence in one place prevents the library and
// personal collection paths from drifting.
func (r *CatalogResolver) resolveCollectionWithEffectiveSort(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
kind, collectionID string,
sortConfig []byte,
resolve func(CatalogRequest) (*CatalogResult, error),
) (*CatalogResult, error) {
// A sort in the request is the viewer's live choice and always wins; only
// when there is none do the saved override and the collection's configured
// default come into play.
if req.UseSourceOrder {
if qs, ok := r.EffectiveCollectionSort(ctx, access, kind, collectionID, sortConfig); ok {
req.Query.Sort = qs
req.UseSourceOrder = false
}
}
result, err := resolve(req)
if err != nil {
return nil, err
}
result.EffectiveSort = req.Query.Sort
return result, nil
}
func (r *CatalogResolver) resolveUserCollectionItems(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
store userstore.UserStore,
collection *userstore.Collection,
) (*CatalogResult, error) {
if IsLiveQueryType(collection.CollectionType) {
def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition))
if err != nil {
@@ -1686,7 +1752,10 @@ func (r *CatalogResolver) catalogStoreForAccess(ctx context.Context, access Acce
return store, nil
}
func catalogProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
// ProfileCanAccessCollection reports whether profileID may view a personal
// collection. Creator access is represented in AllowedProfileIDs at write time,
// so this is the canonical check for both browse and preference endpoints.
func ProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
if collection == nil || strings.TrimSpace(profileID) == "" {
return false
}
@@ -1806,7 +1875,7 @@ func (r *CatalogResolver) loadCollectionSourceBaseItems(ctx context.Context, req
case CatalogSourceLibraryCollection:
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != "visible" {
if err != nil || collection.Visibility != LibraryCollectionVisibilityVisible {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) || catalogCollectionUsesLiveQuery(collection.QueryDefinition) {
@@ -1837,7 +1906,7 @@ func (r *CatalogResolver) loadCollectionSourceBaseItems(ctx context.Context, req
return nil, err
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
var items []*models.MediaItem
+185
View File
@@ -0,0 +1,185 @@
package catalog
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// QueryCollectionItemsBySort loads collection members through the catalog
// query executor, applying an optional personal-collection display filter and
// the resolved sort before the database limit. This keeps rail queries bounded
// without changing the full-match total returned to callers.
func QueryCollectionItemsBySort(
ctx context.Context,
pool *pgxpool.Pool,
contentIDs []string,
qs QuerySort,
access AccessFilter,
limit int,
displayQueryDefinition string,
) ([]*models.MediaItem, int, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, 0, nil
}
def := QueryDefinition{Sort: qs}
if trimmed := strings.TrimSpace(displayQueryDefinition); trimmed != "" {
if err := json.Unmarshal([]byte(trimmed), &def); err != nil {
return nil, 0, fmt.Errorf("parsing collection display_query_definition: %w", err)
}
// Display fragments are filter-only. Never let stale or malformed stored
// execution fields expand scope, replace the selected sort, or set a
// different limit.
def.MediaScope = ""
def.LibraryIDs = nil
def.Sort = qs
def.Limit = nil
}
queryAccess := access
if access.AllowedContentIDs != nil {
queryAccess.AllowedContentIDs = intersectContentIDs(contentIDs, access.AllowedContentIDs)
} else {
queryAccess.AllowedContentIDs = contentIDs
}
if limit <= 0 || limit > len(contentIDs) {
limit = len(contentIDs)
}
executor := &QueryExecutor{Pool: pool}
items, total, err := executor.Preview(ctx, def.Normalize(), queryAccess, limit)
if err != nil {
return nil, 0, fmt.Errorf("querying collection items by %q: %w", qs.Field, err)
}
return items, total, nil
}
// EffectiveCollectionSort resolves the order a collection's items are shown in
// when the request itself carries no sort. Precedence, highest first:
//
// 1. the viewing profile's saved override — set by changing the sort while
// browsing the collection, and sticky from then on;
// 2. the default sort the collection's creator configured (sort_config);
// 3. the collection's own source order, signaled by ok == false.
//
// An override row with an empty sort field is a real choice ("keep source
// order") and short-circuits the creator's default, which is why it returns
// false rather than falling through.
//
// Creator defaults for library collections stay user-agnostic because they are
// shared. A viewer override is profile-scoped, however, so personalized fields
// (progress, date viewed, plays) are valid for either collection kind.
func (r *CatalogResolver) EffectiveCollectionSort(
ctx context.Context,
access AccessFilter,
kind string,
collectionID string,
sortConfig []byte,
) (QuerySort, bool) {
if pref := r.collectionSortOverride(ctx, access, kind, collectionID); pref != nil {
if strings.TrimSpace(pref.SortField) == "" {
return QuerySort{}, false
}
if qs, ok := NormalizeCollectionSort(pref.SortField, pref.SortOrder, true); ok {
return qs, true
}
// A stored override that no longer validates (a sort field retired from
// the vocabulary) falls back to the creator's default rather than
// failing the browse.
}
return ParseCollectionDefaultSort(sortConfig, kind == userstore.CollectionKindUser)
}
// OrderCollectionItemsBySort reorders already-loaded collection members by a
// resolved sort, returning them in that order.
//
// Ordering runs through the catalog query executor constrained to the member
// IDs, so a rail orders items exactly as the collection's browse page does —
// same NULLS LAST handling, same sort_title collation — rather than through a
// second, drifting in-memory comparator. An empty sort field returns items
// untouched (source order). Items the executor does not return (no longer
// visible to this access scope) are dropped, matching the browse path.
func OrderCollectionItemsBySort(
ctx context.Context,
pool *pgxpool.Pool,
items []*models.MediaItem,
qs QuerySort,
access AccessFilter,
) ([]*models.MediaItem, error) {
if strings.TrimSpace(qs.Field) == "" || len(items) == 0 {
return items, nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
if item != nil && strings.TrimSpace(item.ContentID) != "" {
ids = append(ids, item.ContentID)
}
}
if len(ids) == 0 {
return items, nil
}
ordered, _, err := QueryCollectionItemsBySort(ctx, pool, ids, qs, access, len(ids), "")
if err != nil {
return nil, fmt.Errorf("ordering collection items by %q: %w", qs.Field, err)
}
// Preview returns freshly loaded rows; re-project onto the caller's items so
// any enrichment already applied to them survives the reorder.
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
if item != nil {
byID[item.ContentID] = item
}
}
result := make([]*models.MediaItem, 0, len(ordered))
for _, item := range ordered {
if item == nil {
continue
}
if original, ok := byID[item.ContentID]; ok {
result = append(result, original)
}
}
return result, nil
}
// collectionSortOverride loads the viewing profile's saved sort for a
// collection, or nil when there is none. A lookup failure is logged and treated
// as "no override": a browse must not fail because a preference row could not
// be read.
func (r *CatalogResolver) collectionSortOverride(
ctx context.Context,
access AccessFilter,
kind string,
collectionID string,
) *userstore.CollectionSortPreference {
if r.storeProvider == nil || access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil
}
store, err := r.storeProvider.ForUser(ctx, access.UserID)
if err != nil || store == nil {
if err != nil {
slog.WarnContext(ctx, "loading user store for collection sort preference",
"collection_kind", kind, "collection_id", collectionID, "error", err)
}
return nil
}
pref, err := store.GetCollectionSortPreference(ctx, access.ProfileID, kind, collectionID)
if err != nil {
slog.WarnContext(ctx, "loading collection sort preference",
"collection_kind", kind, "collection_id", collectionID, "error", err)
return nil
}
return pref
}
+289
View File
@@ -0,0 +1,289 @@
package catalog
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/userstore/pgstore"
)
// These tests run the default-sort / saved-override flow against a real
// Postgres: real SQL ordering through the query executor, and a real
// PostgresUserStore writing the override row. They cover the reported
// behavior end to end — a collection's creator picks a default order, a viewer
// re-sorts, and the viewer's choice survives a fresh read.
//
// Set SILO_TEST_DATABASE_URL to a migrated database to run them.
func collectionSortTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.user_collection_sort_preferences')::text`).Scan(&tableName); err != nil {
t.Fatalf("check user_collection_sort_preferences table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the collection sort preference migration")
}
return pool
}
// seedSortableItem inserts one sortable item. Callers choose title/year pairs
// that make a title sort and a year sort disagree, so an order assertion cannot
// pass by coincidence.
func seedSortableItem(t *testing.T, pool *pgxpool.Pool, contentID, title string, year int) {
t.Helper()
ctx := context.Background()
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, sort_title, year, genres)
VALUES ($1, 'movie', $2, $2, $3, '{}'::text[])
`, contentID, title, year); err != nil {
t.Fatalf("seed media item %s: %v", contentID, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id = $1`, contentID)
})
}
func seedSortTestUser(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
ctx := context.Background()
var userID int
// A bare fixture row: the override table has an FK to users(id) and the
// test needs something to point at. No credentials are set.
if err := pool.QueryRow(ctx, `
INSERT INTO users (username, email, password_hash, role)
VALUES ('collection-sort-fixture', 'collection-sort-fixture@invalid.test', '', 'user')
RETURNING id
`).Scan(&userID); err != nil {
t.Fatalf("seed fixture user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
return userID
}
func itemsForSort(contentIDs ...string) []*models.MediaItem {
items := make([]*models.MediaItem, 0, len(contentIDs))
for _, id := range contentIDs {
items = append(items, &models.MediaItem{ContentID: id})
}
return items
}
func orderedIDs(items []*models.MediaItem) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ContentID)
}
return ids
}
func assertOrder(t *testing.T, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("order = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
// TestCollectionDefaultSortAndOverrideEndToEnd walks the whole reported
// scenario against real Postgres, for both collection kinds.
func TestCollectionDefaultSortAndOverrideEndToEnd(t *testing.T) {
pool := collectionSortTestPool(t)
ctx := context.Background()
// Years are shuffled against titles on purpose: title-asc gives A,B,C but
// year-desc gives B,C,A. If the two agreed, an assertion on the override's
// order would also pass when the override was ignored entirely.
seedSortableItem(t, pool, "cs-a", "Alpha", 2001)
seedSortableItem(t, pool, "cs-b", "Bravo", 2003)
seedSortableItem(t, pool, "cs-c", "Charlie", 2002)
// Source order is deliberately neither sorted order.
sourceOrder := itemsForSort("cs-c", "cs-a", "cs-b")
limited, total, err := QueryCollectionItemsBySort(
ctx,
pool,
[]string{"cs-c", "cs-a", "cs-b"},
QuerySort{Field: "title", Order: "asc"},
AccessFilter{},
2,
"",
)
if err != nil {
t.Fatalf("querying limited sorted collection: %v", err)
}
assertOrder(t, orderedIDs(limited), "cs-a", "cs-b")
if total != 3 {
t.Fatalf("limited sorted collection total = %d, want 3", total)
}
userID := seedSortTestUser(t, pool)
provider := pgstore.NewPostgresProvider(pool)
store, err := provider.ForUser(ctx, userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
if err := store.CreateProfile(ctx, userstore.Profile{ID: "profile-e2e", Name: "E2E sort profile"}); err != nil {
t.Fatalf("seed profile: %v", err)
}
t.Cleanup(func() { _ = store.DeleteProfile(context.Background(), "profile-e2e") })
resolver := &CatalogResolver{storeProvider: provider}
access := AccessFilter{UserID: userID, ProfileID: "profile-e2e"}
for _, kind := range []string{userstore.CollectionKindLibrary, userstore.CollectionKindUser} {
t.Run(kind, func(t *testing.T) {
collectionID := "cs-collection-" + kind
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(ctx, access.ProfileID, kind, collectionID)
})
// 1. No default, no override — the collection keeps source order.
if _, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, []byte(`{}`)); ok {
t.Fatal("a collection with no default reported a sort")
}
// 2. The creator sets a default of Title (A→Z).
defaultSort := []byte(`{"field":"title","order":"asc"}`)
qs, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok {
t.Fatal("configured default sort was not applied")
}
ordered, err := OrderCollectionItemsBySort(ctx, pool, sourceOrder, qs, access)
if err != nil {
t.Fatalf("ordering by default sort: %v", err)
}
assertOrder(t, orderedIDs(ordered), "cs-a", "cs-b", "cs-c")
// 3. The viewer re-sorts to Year (newest first) and it is saved.
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: access.ProfileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: "year",
SortOrder: "desc",
}); err != nil {
t.Fatalf("saving override: %v", err)
}
// 4. A fresh resolution — the "leave and come back" case — reads the
// override back out of the database and beats the creator's default.
freshResolver := &CatalogResolver{storeProvider: pgstore.NewPostgresProvider(pool)}
qs, ok = freshResolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok {
t.Fatal("saved override was not applied on a fresh read")
}
if qs.Field != "year" || qs.Order != "desc" {
t.Fatalf("effective sort = %q/%q, want year/desc", qs.Field, qs.Order)
}
ordered, err = OrderCollectionItemsBySort(ctx, pool, sourceOrder, qs, access)
if err != nil {
t.Fatalf("ordering by override: %v", err)
}
// Distinct from both the default's A,B,C and source order's C,A,B.
assertOrder(t, orderedIDs(ordered), "cs-b", "cs-c", "cs-a")
assertOrder(t, orderedIDs(sourceOrder), "cs-c", "cs-a", "cs-b")
// 5. The viewer pins back to the collection's own order. That is a
// real choice and must suppress the creator's default, not fall
// through to it.
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: access.ProfileID,
CollectionKind: kind,
CollectionID: collectionID,
SortField: "",
SortOrder: "",
}); err != nil {
t.Fatalf("pinning to source order: %v", err)
}
if _, ok := resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort); ok {
t.Fatal("source-order override fell through to the creator's default")
}
// 6. Clearing the override returns the viewer to the default.
if err := store.ClearCollectionSortPreference(ctx, access.ProfileID, kind, collectionID); err != nil {
t.Fatalf("clearing override: %v", err)
}
qs, ok = resolver.EffectiveCollectionSort(ctx, access, kind, collectionID, defaultSort)
if !ok || qs.Field != "title" {
t.Fatalf("after clearing, effective sort = %q (ok=%v), want title", qs.Field, ok)
}
})
}
}
// TestCollectionSortOverrideIsPerProfile guards the isolation that makes a
// server-wide collection safe to re-sort: one profile's choice must not change
// what another profile sees.
func TestCollectionSortOverrideIsPerProfile(t *testing.T) {
pool := collectionSortTestPool(t)
ctx := context.Background()
userID := seedSortTestUser(t, pool)
provider := pgstore.NewPostgresProvider(pool)
store, err := provider.ForUser(ctx, userID)
if err != nil {
t.Fatalf("user store: %v", err)
}
for _, profile := range []userstore.Profile{
{ID: "profile-one", Name: "Profile One"},
{ID: "profile-two", Name: "Profile Two"},
} {
if err := store.CreateProfile(ctx, profile); err != nil {
t.Fatalf("seed profile %s: %v", profile.ID, err)
}
}
t.Cleanup(func() {
_ = store.DeleteProfile(context.Background(), "profile-two")
_ = store.DeleteProfile(context.Background(), "profile-one")
})
resolver := &CatalogResolver{storeProvider: provider}
const collectionID = "cs-shared-collection"
defaultSort := []byte(`{"field":"title","order":"asc"}`)
kind := userstore.CollectionKindLibrary
if err := store.SetCollectionSortPreference(ctx, userstore.CollectionSortPreference{
ProfileID: "profile-one",
CollectionKind: kind,
CollectionID: collectionID,
SortField: "year",
SortOrder: "desc",
}); err != nil {
t.Fatalf("saving override: %v", err)
}
t.Cleanup(func() {
_ = store.ClearCollectionSortPreference(context.Background(), "profile-one", kind, collectionID)
})
one, ok := resolver.EffectiveCollectionSort(ctx, AccessFilter{UserID: userID, ProfileID: "profile-one"}, kind, collectionID, defaultSort)
if !ok || one.Field != "year" {
t.Fatalf("profile-one sort = %q (ok=%v), want year", one.Field, ok)
}
two, ok := resolver.EffectiveCollectionSort(ctx, AccessFilter{UserID: userID, ProfileID: "profile-two"}, kind, collectionID, defaultSort)
if !ok || two.Field != "title" {
t.Fatalf("profile-two sort = %q (ok=%v), want the collection default title", two.Field, ok)
}
}
@@ -0,0 +1,189 @@
package catalog
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// sortPrefStore is a UserStore that only answers collection-sort-preference
// lookups; every other method panics so a test that strays off this path fails
// loudly rather than silently exercising unrelated behavior.
type sortPrefStore struct {
userstore.UserStore
pref *userstore.CollectionSortPreference
err error
// calls records the lookups made, so tests can assert the store is not
// consulted when there is no user scope to consult it with.
calls int
}
func (s *sortPrefStore) GetCollectionSortPreference(_ context.Context, _, _, _ string) (*userstore.CollectionSortPreference, error) {
s.calls++
if s.err != nil {
return nil, s.err
}
return s.pref, nil
}
type sortPrefProvider struct {
store *sortPrefStore
err error
}
func (p *sortPrefProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
if p.err != nil {
return nil, p.err
}
return p.store, nil
}
func (p *sortPrefProvider) Close() error { return nil }
func TestEffectiveCollectionSortPrecedence(t *testing.T) {
const (
libraryDefault = `{"field":"added_at","order":"desc"}`
noDefault = `{}`
)
scopedAccess := AccessFilter{UserID: 7, ProfileID: "profile-1"}
cases := []struct {
name string
access AccessFilter
sortConfig string
pref *userstore.CollectionSortPreference
storeErr error
wantOK bool
wantField string
wantOrder string
wantCalls int
}{
{
name: "no override falls back to the collection default",
access: scopedAccess,
sortConfig: libraryDefault,
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
// The reported behavior: admin defaults to Date Added, the viewer
// switches to Title, and Title is what they get from then on.
name: "viewer override beats the collection default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "title", SortOrder: "asc"},
wantOK: true,
wantField: "title",
wantOrder: "asc",
wantCalls: 1,
},
{
// An empty stored field is a deliberate "put it back to the list's
// own order" and must not fall through to the creator's default.
name: "override pinned to source order suppresses the default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "", SortOrder: ""},
wantCalls: 1,
},
{
name: "no override and no default means source order",
access: scopedAccess,
sortConfig: noDefault,
wantCalls: 1,
},
{
name: "personalized viewer override is valid on a library collection",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "progress", SortOrder: "desc"},
wantOK: true,
wantField: "progress",
wantOrder: "desc",
wantCalls: 1,
},
{
name: "unusable override falls back to the default",
access: scopedAccess,
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "retired_sort", SortOrder: "desc"},
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
name: "store failure degrades to the default",
access: scopedAccess,
sortConfig: libraryDefault,
storeErr: errors.New("boom"),
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 1,
},
{
// Unauthenticated / profile-less reads (jellycompat, public paths)
// have no override to look up and must not query for one.
name: "no user scope skips the override lookup",
access: AccessFilter{},
sortConfig: libraryDefault,
pref: &userstore.CollectionSortPreference{SortField: "title", SortOrder: "asc"},
wantOK: true,
wantField: "added_at",
wantOrder: "desc",
wantCalls: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := &sortPrefStore{pref: tc.pref, err: tc.storeErr}
resolver := &CatalogResolver{storeProvider: &sortPrefProvider{store: store}}
qs, ok := resolver.EffectiveCollectionSort(
context.Background(),
tc.access,
userstore.CollectionKindLibrary,
"collection-1",
[]byte(tc.sortConfig),
)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if store.calls != tc.wantCalls {
t.Fatalf("override lookups = %d, want %d", store.calls, tc.wantCalls)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestEffectiveCollectionSortAllowsPersonalizedOnUserCollections(t *testing.T) {
store := &sortPrefStore{pref: &userstore.CollectionSortPreference{SortField: "progress", SortOrder: "desc"}}
resolver := &CatalogResolver{storeProvider: &sortPrefProvider{store: store}}
qs, ok := resolver.EffectiveCollectionSort(
context.Background(),
AccessFilter{UserID: 7, ProfileID: "profile-1"},
userstore.CollectionKindUser,
"collection-1",
[]byte(`{}`),
)
if !ok {
t.Fatal("personalized override rejected on a personal collection")
}
if qs.Field != "progress" || qs.Order != "desc" {
t.Fatalf("got %q/%q, want progress/desc", qs.Field, qs.Order)
}
}
+105
View File
@@ -0,0 +1,105 @@
package catalog
import "testing"
func TestNormalizeCollectionSort(t *testing.T) {
cases := []struct {
name string
field string
order string
allowPersonalized bool
wantOK bool
wantField string
wantOrder string
}{
{name: "applies the field's default order", field: "release_date", wantOK: true, wantField: "release_date", wantOrder: "desc"},
{name: "title defaults ascending", field: "title", wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "explicit order wins", field: "title", order: "desc", wantOK: true, wantField: "title", wantOrder: "desc"},
{name: "trims and lowercases", field: " Title ", order: "ASC", wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "garbage order is rejected", field: "year", order: "sideways"},
{name: "empty field means source order", field: ""},
{name: "unknown field is rejected", field: "not_a_sort"},
{name: "personalized rejected for library collections", field: "progress"},
{
name: "personalized allowed for personal collections",
field: "progress",
allowPersonalized: true,
wantOK: true,
wantField: "progress",
wantOrder: "desc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
qs, ok := NormalizeCollectionSort(tc.field, tc.order, tc.allowPersonalized)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestParseCollectionDefaultSort(t *testing.T) {
cases := []struct {
name string
raw string
wantOK bool
wantField string
wantOrder string
}{
{name: "empty column", raw: ""},
{name: "legacy empty object", raw: `{}`},
{name: "json null", raw: `null`},
{name: "unparseable json", raw: `{`},
// The unimplemented manual-pin mode carries no field, so it must not be
// mistaken for a configured sort.
{name: "manual pins mode", raw: `{"mode":"manual_pins"}`},
{name: "configured sort", raw: `{"field":"title","order":"asc"}`, wantOK: true, wantField: "title", wantOrder: "asc"},
{name: "order filled in", raw: `{"field":"added_at"}`, wantOK: true, wantField: "added_at", wantOrder: "desc"},
{name: "invalid order is rejected", raw: `{"field":"title","order":"sideways"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
qs, ok := ParseCollectionDefaultSort([]byte(tc.raw), false)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !tc.wantOK {
return
}
if qs.Field != tc.wantField || qs.Order != tc.wantOrder {
t.Fatalf("got %q/%q, want %q/%q", qs.Field, qs.Order, tc.wantField, tc.wantOrder)
}
})
}
}
func TestEncodeCollectionDefaultSort(t *testing.T) {
encoded, err := EncodeCollectionDefaultSort(QuerySort{Field: "title", Order: "asc"}, true)
if err != nil {
t.Fatalf("encoding: %v", err)
}
if encoded != `{"field":"title","order":"asc"}` {
t.Fatalf("encoded = %s", encoded)
}
// Source order round-trips through the empty object the column defaults to.
encoded, err = EncodeCollectionDefaultSort(QuerySort{}, false)
if err != nil {
t.Fatalf("encoding source order: %v", err)
}
if encoded != "{}" {
t.Fatalf("source order encoded = %s, want {}", encoded)
}
if _, ok := ParseCollectionDefaultSort([]byte(encoded), false); ok {
t.Fatal("re-parsing the source-order encoding reported a configured sort")
}
}
+6 -3
View File
@@ -3105,6 +3105,7 @@ type audioPrefResolver struct {
store userstore.UserStore
valid bool
profileID string
deviceID string
contentID string
profileDone bool
@@ -3136,6 +3137,7 @@ func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessF
r := &audioPrefResolver{
svc: s,
profileID: filter.ProfileID,
deviceID: filter.DeviceID,
contentID: audioPreferenceContentID,
resolvedLang: map[int]string{},
}
@@ -3176,15 +3178,16 @@ func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.Audio
// audioLanguage resolves with every content identity in context. The language
// preference lives in the canonical table at profile_series/profile_library/
// profile scopes; the specialized audio row supplies only concrete track
// identity. Caching by library keeps a multi-file item at one canonical read
// per distinct folder rather than one read per file.
// profile_device/profile scopes; the specialized audio row supplies only
// concrete track identity. Caching by library keeps a multi-file item at one
// canonical read per distinct folder rather than one read per file.
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
if lang, ok := r.resolvedLang[libraryID]; ok {
return lang
}
rc := settingsresolve.Context{
ProfileID: r.profileID,
DeviceID: r.deviceID,
LibraryIDs: []int{libraryID},
}
if strings.TrimSpace(r.contentID) != "" {

Some files were not shown because too many files have changed in this diff Show More