Compare commits

...
Author SHA1 Message Date
Quick104 fd3d737a58 fix(playback): trust completed segment writes 2026-08-11 15:02:38 -04:00
Quick104 86a02a6f61 fix(playback): bound sparse seek cleanup 2026-08-11 14:55:27 -04:00
Quick104 1cbb59d0fa fix(playback): finish segment acknowledgements 2026-08-11 14:45:33 -04:00
Quick104 874a26de0f fix(playback): confirm proxy downstream delivery 2026-08-11 14:30:55 -04:00
Quick104 0544213730 fix(playback): verify segment completion before pruning 2026-08-11 14:21:33 -04:00
Quick104 e05c30c637 fix(playback): keep restart buffers prunable 2026-08-11 13:42:43 -04:00
Quick104 7f6225bb11 fix(playback): remove redundant retention floor assignment 2026-08-11 13:21:53 -04:00
Quick104 977104eac5 Merge remote-tracking branch 'origin/main' into t3code/pr565-review 2026-08-11 13:14:44 -04:00
Quick104 dffa6ae34e Merge origin/main into codex/bound-transcode-segments 2026-08-11 13:05:12 -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
Quick104 e2899ff619 fix(playback): preserve retained segments across restarts 2026-08-11 12:59:25 -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
Quick104 f524c48d19 fix(playback): close retention gaps across ranges and seeks 2026-08-08 18:49:27 -04:00
Quick104 c26ab22809 test(playback): strengthen retention regression coverage 2026-08-08 16:08:31 -04:00
Quick104 0a3adf6552 fix(playback): harden segment retention lifecycle 2026-08-08 15:59:56 -04:00
Quick104 35de68b589 fix(playback): synchronize prune option snapshot 2026-08-08 15:29:02 -04:00
Quick104 6a3fcef165 fix(playback): prune downloaded transcode segments 2026-08-08 15:23:16 -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
fa1d7ba2b4 fix(playback): freeze v3 seek reanchor recipes (#548)
* fix(playback): freeze v3 seek reanchor recipes

* fix(playback): address v3 recipe review feedback

* test(playback): isolate v3 fallback route fixture

* fix(database): split v3 recipe constraint validation

* fix(playback): preserve frozen seek recipe identity

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:43:21 -04:00
40a9de7f26 feat(watchsync): add plugin-backed providers (#475)
* feat(watchsync): add plugin-backed providers

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

* fix(watchsync): keep device state host-private

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

* chore(deps): use released watch-sync SDK

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:30:49 -04:00
QuickandGitHub 31a26b2554 fix(scanner): accept corroborated long video durations (#545)
* fix(scanner): accept corroborated long video durations

* fix(scanner): harden long duration fallbacks

* fix(scanner): address long duration review feedback

* fix(scanner): align long duration seek semantics

* fix(scanner): align legacy long media seeking

* fix(scanner): preserve long timeline origins

* fix(scanner): preserve long audio-switch timelines

* fix(scanner): distinguish absolute-end timelines

* fix(scanner): preserve long resume semantics

* fix(scanner): reject ambiguous absolute ends

* fix(scanner): preserve compat real-manifest resumes
2026-08-05 20:27:42 -04:00
QuickandGitHub 8bde6f1156 feat(admin): improve responsive dashboard and settings (#539) 2026-08-04 11:43:50 -04:00
567cdd1a15 feat(playback): balance transcode sessions across multiple GPUs (#425)
* feat(playback): balance transcode sessions across multiple GPUs

playback.hw_device now accepts a comma-separated render-device list (e.g.
"/dev/dri/renderD128,/dev/dri/renderD129"). Each transcode session resolves
the list to one concrete device at spawn — the present device with the
fewest active GPU sessions, ties keeping list order — and holds that device
for its whole lifetime (seek/audio restarts reuse it); the reservation
releases on session shutdown, idempotently, including early spawn-failure
paths. Software-accel sessions never reserve, so they cannot skew the
balance.

A single configured value keeps the historical pass-through contract and an
empty value still auto-detects, so existing deployments are unaffected.
PickRenderDevice is list-aware too, picking least-loaded without reserving,
which lets the non-session consumers (chapter thumbnails, download
artifacts, transcode nodes) spread load best-effort when given a list.

Motivation: hosts with two identical media GPUs (e.g. dual Arc A310)
previously pinned every session to one device while the second sat idle.

* feat(admin): GPU device picker for playback hw_device

The hw-accel detection endpoint now reports render_device_details — each
render device with a human label derived from its sysfs PCI vendor/device
ids ("Intel GPU (0x56a6)") — and the Playback settings page renders them as
per-device toggles instead of requiring a hand-typed device path. No
selection means auto (first available device); one selection pins every
session; multiple selections balance least-loaded. The stored
playback.hw_device value stays the comma-separated list, written in stable
detection order regardless of click order, and a configured-but-undetected
device stays visible so a temporarily missing GPU is not silently dropped
on save.

* fix(playback): make GPU selection and reservation atomic

Review follow-up: resolveSessionHWDevice previously selected the
least-loaded device and incremented its count in two separate critical
sections, so concurrent session starts could all pick the same device
before any reservation landed. Device presence checks now happen outside
the lock and selection + reservation share one critical section; a
concurrency test asserts an exact split across two devices for eight
simultaneous starts, which the two-step version cannot guarantee.

* refactor(playback): one typed GPU acquisition boundary, release on process exit

Replace the CSV-handling spread across resolveSessionHWDevice and
PickRenderDevice with HWDeviceSet + AcquireHWDevice in hwdevice.go: every
GPU workload resolves exactly one device immediately before spawn.
Balancing is explicitly QSV/VAAPI-only — NVENC addresses GPUs by CUDA
index/UUID, so a multi-entry list warns and uses the first entry instead
of collapsing through the path-presence filter. Sessions now release
their reservation only after ffmpeg has been reaped (shutdown waits on
done first), closing the window where a new start could pick a device
the old process still occupied. Render-device sysfs descriptions move to
gpudetect.go so the allocator file owns only selection/reservation.

* fix(downloads): prepared downloads acquire a GPU through the shared pool

PrepareFile resolves the configured hw_device list to one concrete
device via AcquireHWDevice and holds the reservation until ffmpeg exits
(Run is synchronous, so the deferred release is the process-exit
boundary). Download encodes now participate in the same active-load
accounting as streaming sessions instead of best-effort spreading.

* fix(chapterthumbs): resolve hw_device list per extraction via the shared pool

ExtractFrame acquires one concrete device from AcquireHWDevice for the
hardware attempt (released when the attempt finishes) instead of passing
the raw comma-separated value to ffmpeg as a single device path. The
service stops pre-resolving and caching a device at first use — the raw
configured value flows through and each extraction resolves it.

* fix(transcodenode): fresh starts use this node's configured hw_device

/transcode/start constructed TranscodeOpts with an empty HWDevice, so
fresh sessions auto-detected the first GPU and bypassed the configured
list while reconstructed sessions honored it. Both paths now feed the
node-local config value into StartTranscode's shared resolution.

* feat(admin): node-aware GPU inventory on /admin/system/hw-accel

playback.hw_device is one cluster-wide value consumed by every transcode
node, but the endpoint probed only whichever healthy node had the fewest
jobs — an admin could configure devices that don't exist on the other
nodes. The endpoint now probes every healthy node concurrently and
returns a nodes array (URL, name, resolved accel, devices, or probe
error) alongside the backward-compatible flat fields, and the config doc
states the homogeneous-path contract explicitly.

* feat(admin): GPU picker survives empty detection, warns on node divergence

The picker rows are now the union of detected devices and configured
entries, so configured-but-missing devices stay visible (and
deselectable) when detection returns nothing or an older node omits
render_device_details (plain render_devices paths fall back to a generic
label). Per-node inventories from the hw-accel endpoint drive two
warnings: a banner when responding nodes report different device sets,
and a per-row note listing nodes missing that device. The multi-select
is hidden for NVENC — balancing is QSV/VA-API only — with a notice when
a multi-device value is already stored.

* style: gofmt touched files

* fix(playback): release GPU reservations on process exit

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-04 11:33:15 -04:00
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00
QuickandGitHub 4cd51d8f6c feat(settings): add mobile-first settings overviews (#537)
* feat(settings): add mobile-first settings overview

* feat(admin): add mobile settings overview

* fix(settings): address mobile navigation review
2026-08-03 22:28:31 -04:00
QuickandGitHub b0418a4430 fix(web): stop library state request loop (#536)
* fix(web): stop library state request loop

* fix(web): harden library state persistence

* fix(web): reconcile rejected library state writes

* fix(web): make library state persistence lossless

* fix(web): serialize library state across remounts

* fix(web): pin profile identity across auth refresh

* fix(web): scope profile verification cleanup

* fix(web): isolate library state by account
2026-08-03 21:51:50 -04:00
c1cac4ece9 fix(auth): bound device match codes to eight letters (#535)
* fix(auth): bound device match codes to eight letters

* fix(auth): use farm-themed device match codes

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-03 12:41:51 -04:00
409 changed files with 52358 additions and 11761 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
+65 -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,39 @@ 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
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.
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.
### Deployment Notes
@@ -116,7 +146,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
@@ -183,24 +216,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
@@ -212,6 +247,10 @@ If you prefer running Silo without Docker:
## Reporting Issues
Client implementers can use the [Canonical Settings API guide](docs/settings-api.md)
for contract discovery, contextual headers, remote scopes, effective reads, and
the admin projection.
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
Please include:
+858
View File
@@ -0,0 +1,858 @@
// 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
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: "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},
})
}
+1
View File
@@ -157,6 +157,7 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
}
out.WriteString(" */\n\n")
fmt.Fprintf(&out, "export const SETTINGS_API_VERSION = %d;\n", contract.APIVersion)
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
out.WriteString("export interface SettingSuggestedOption {\n")
+103 -5
View File
@@ -19,6 +19,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -893,8 +894,10 @@ func main() {
)
}
var watchProviderService *watchsync.Service
var watchProviderRegistry *watchsync.Registry
var watchProviderRepo *watchsync.PostgresRepository
if deps.DB != nil {
watchProviderRegistry := watchsync.NewRegistry()
watchProviderRegistry = watchsync.NewRegistry()
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
@@ -904,10 +907,8 @@ func main() {
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
watchProviderService = watchsync.NewService(
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
watchProviderRegistry,
)
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
deps.WatchProviderService = watchProviderService
}
@@ -1119,6 +1120,15 @@ func main() {
installer,
plugins.NewHostAdapter(pluginHost),
)
if watchProviderRegistry != nil {
reloadWatchProviders := func(ctx context.Context) {
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
}
}
pluginService.AddLifecycleHook(reloadWatchProviders)
reloadWatchProviders(appCtx)
}
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
pluginService.AddLifecycleHook(func(ctx context.Context) {
@@ -3088,11 +3098,99 @@ func metadataInt(value any) int {
}
}
var watchSyncPluginReloadMu sync.Mutex
type markerPluginCapabilityStore interface {
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
}
func reloadWatchSyncPluginProviders(
ctx context.Context,
registry *watchsync.Registry,
store markerPluginCapabilityStore,
service *plugins.Service,
repository watchsync.PluginCredentialRepository,
) error {
if registry == nil {
return nil
}
watchSyncPluginReloadMu.Lock()
defer watchSyncPluginReloadMu.Unlock()
var providers []watchsync.Provider
if store == nil || service == nil {
return registry.ReplacePluginProviders(providers)
}
installations, err := store.ListEnabled(ctx)
if err != nil {
return fmt.Errorf("list enabled watch sync plugin installations: %w", err)
}
sort.Slice(installations, func(i, j int) bool {
if installations[i] == nil {
return false
}
if installations[j] == nil {
return true
}
return installations[i].ID < installations[j].ID
})
for _, installation := range installations {
if installation == nil || installation.IsBuiltin() {
continue
}
capabilities, err := store.ListCapabilities(ctx, installation.ID)
if err != nil {
slog.WarnContext(ctx, "skip watch sync plugin with unreadable capabilities",
"component", "app",
"installation_id", installation.ID,
"error", err,
)
continue
}
for _, capability := range capabilities {
if capability == nil || capability.Type != sdkcapability.WatchSyncProvider {
continue
}
descriptor, err := plugins.DecodeCapability(capability)
if err != nil {
slog.WarnContext(ctx, "skip invalid watch sync plugin capability",
"component", "app",
"installation_id", installation.ID,
"capability_id", capability.ID,
"error", err,
)
continue
}
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
InstallationID: installation.ID,
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
CapabilityID: capability.ID,
DisplayName: descriptor.GetDisplayName(),
Descriptor: descriptor.GetWatchSyncProvider(),
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
},
ResolveConfig: func(callCtx context.Context, installationID int) (*pluginv1.WatchSyncProviderConfig, error) {
return service.WatchSyncProviderConfig(callCtx, installationID)
},
Repository: repository,
})
if err != nil {
slog.WarnContext(ctx, "skip unsupported watch sync plugin capability",
"component", "app",
"installation_id", installation.ID,
"capability_id", capability.ID,
"error", err,
)
continue
}
providers = append(providers, provider)
}
}
return registry.ReplacePluginProviders(providers)
}
type markerPluginRuntimeConfigStore interface {
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
+46
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -8,9 +10,12 @@ import (
"sync"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
@@ -182,3 +187,44 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
})
}
}
type failingWatchSyncCapabilityStore struct{}
func (failingWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
return []*plugins.Installation{{ID: 2, Enabled: true, Kind: plugins.KindPlugin}}, nil
}
func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
return nil, errors.New("database unavailable")
}
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
registry := watchsync.NewRegistry()
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
InstallationID: 1,
ProviderKey: "plugin:1:tracker",
CapabilityID: "tracker",
Descriptor: &pluginv1.WatchSyncProviderDescriptor{
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
ExportWatched: true,
},
ResolveClient: func(context.Context, int, string) (watchsync.WatchSyncPluginClient, error) {
return nil, errors.New("not used")
},
})
if err != nil {
t.Fatal(err)
}
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
if err := reloadWatchSyncPluginProviders(
context.Background(), registry, failingWatchSyncCapabilityStore{}, &plugins.Service{}, nil,
); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get(provider.Key()); ok {
t.Fatalf("stale provider %q remained registered", provider.Key())
}
}
+99 -1
View File
@@ -1,8 +1,82 @@
{
"fixture_version": 1,
"manifest_revision": 4,
"manifest_revision": 6,
"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": [
{
"name": "profile_client_sits_between_device_and_profile",
"description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.",
"keys": ["ui.card_presentation"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" },
"stored": [
{
"key": "ui.card_presentation",
"scope": "profile",
"profile_id": "p1",
"value": { "poster_size": "standard", "caption": "title" }
},
{
"key": "ui.card_presentation",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": { "poster_size": "large", "caption": "artwork" }
},
{
"key": "ui.card_presentation",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "living-room",
"value": { "poster_size": "compact", "caption": "title_metadata" }
}
],
"expected": [
{
"key": "ui.card_presentation",
"value": { "poster_size": "compact", "caption": "title_metadata" },
"source": "profile_device"
}
]
},
{
"name": "profile_client_roams_only_within_its_family",
"description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.",
"keys": ["nav.primary_menu"],
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" },
"stored": [
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "mobile",
"value": { "items": [{ "type": "builtin", "destination": "home" }] }
},
{
"key": "nav.primary_menu",
"scope": "profile_client",
"profile_id": "p1",
"client_family": "tv",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
}
}
],
"expected": [
{
"key": "nav.primary_menu",
"value": {
"items": [
{ "type": "builtin", "destination": "home" },
{ "type": "builtin", "destination": "movies" }
]
},
"source": "profile_client"
}
]
},
{
"name": "resolution_order_series_wins",
"description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.",
@@ -593,6 +667,30 @@
"source": "profile"
}
]
},
{
"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"
}
]
}
]
}
+56 -3
View File
@@ -1,6 +1,6 @@
{
"api_version": 1,
"revision": 4,
"revision": 6,
"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",
@@ -827,6 +827,59 @@
"recommended_control": "select",
"notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member."
},
{
"key": "nav.primary_menu",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile_client", "profile_device"],
"resolution_order": ["profile_device", "profile_client", "default"],
"value_schema": {
"type": "object",
"schema_ref": "primary-menu.json",
"nullable": true
},
"default_value": null,
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
"category": "navigation",
"label": "Primary menu",
"description": "The ordered visible destinations shared by like clients.",
"recommended_control": "panel",
"notes": "Search and profile remain fixed client utilities. Home is required by primary-menu.json; omitting any other supported built-in hides it. Semantic destination identities are unique even when labels differ. A null default lets each family keep its native baseline until the user customizes it. Family scope synchronizes like clients while profile_device remains an explicit escape hatch for one screen; clients ignore built-in destinations they do not support."
},
{
"key": "nav.shortcuts",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile"],
"resolution_order": ["profile", "default"],
"value_schema": {
"type": "object",
"schema_ref": "navigation-shortcuts.json"
},
"default_value": { "items": [] },
"category": "navigation",
"label": "Navigation shortcuts",
"description": "Libraries, sections, and collections pinned for use across navigation surfaces.",
"notes": "Profile-wide catalog; individual client families decide which shortcuts to place in their primary menu. Semantic destination identities are unique even when labels differ. The profile_client migration keeps ui.sidebar_pins unchanged and seeds this key from convertible legacy web pins only when this key has no authored row."
},
{
"key": "ui.card_presentation",
"introduced_in": 5,
"persistence": "remote",
"allowed_scopes": ["profile", "profile_client", "profile_device"],
"resolution_order": ["profile_device", "profile_client", "profile", "default"],
"value_schema": {
"type": "object",
"schema_ref": "card-presentation.json"
},
"default_value": { "poster_size": "standard", "caption": "title_metadata" },
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
"category": "appearance",
"label": "Media cards",
"description": "Poster size and caption detail used by media cards.",
"recommended_control": "panel",
"notes": "Semantic presets roam between like devices without forcing identical pixel dimensions across platforms. A profile fallback can opt into one presentation everywhere; family and exact-device values remain more specific."
},
{
"key": "ui.sidebar_pins",
"introduced_in": 1,
@@ -76,6 +76,7 @@
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
@@ -291,6 +292,7 @@
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-presentation.json",
"title": "Media card presentation",
"description": "Semantic density and caption presets that each client family maps to platform-appropriate card dimensions.",
"type": "object",
"additionalProperties": false,
"required": ["poster_size", "caption"],
"properties": {
"poster_size": {
"type": "string",
"enum": ["compact", "standard", "large"]
},
"caption": {
"type": "string",
"enum": ["title_metadata", "title", "artwork"]
}
}
}
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/navigation-shortcuts.json",
"title": "Navigation shortcuts",
"description": "Profile-wide catalog of pinned libraries, sections, and collections that client-family menus may surface.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Pinned targets. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"maxItems": 256,
"uniqueItems": true,
"items": {
"oneOf": [
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
@@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/primary-menu.json",
"title": "Primary navigation menu",
"description": "Ordered visible destinations for one client family. Search and profile controls are fixed client utilities and are intentionally absent.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: builtin(destination), library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Ordered destinations. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"minItems": 1,
"maxItems": 64,
"uniqueItems": true,
"contains": {
"type": "object",
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": { "const": "home" }
}
},
"minContains": 1,
"maxContains": 1,
"items": {
"oneOf": [
{ "$ref": "#/$defs/builtin" },
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"builtin": {
"type": "object",
"additionalProperties": false,
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": {
"type": "string",
"enum": ["home", "movies", "series", "music", "audiobooks", "for_you", "calendar"]
}
}
},
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
-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
+819
View File
@@ -0,0 +1,819 @@
# 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", "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 eight 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_change` / `quality_change` as 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) |
| `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.
---
## 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 requested position. So `player_start` is `0`,
`stream_origin` and `timeline_offset` both equal the seek position,
`seek_window_start_seconds` is that position, 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 behaves the same way: player-zero is stream-zero is the seek
position, 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 five:
| 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` |
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.
**User-intent operations behave differently from failure recovery.**
`track_change` and `quality_change` replace what were separate v2 endpoints (an
audio PATCH and a client-posted transcode start). Nothing failed, so the previous
route stays eligible: neither the attempted-key history nor the failed-plan
exclusion applies. 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 or
quality 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 a user switching tracks may well
want the original file back. 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`.
*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)
@@ -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,793 @@
{
"$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"
},
"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,50 @@
{
"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",
"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,180 @@
{
"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",
"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,663 @@
{
"$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"
],
"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. A track_change or quality_change carries 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"
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
}
},
"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
},
"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"
}
}
}
}
],
"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,545 @@
{
"$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"
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
}
},
"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
},
"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
}
+198
View File
@@ -0,0 +1,198 @@
# Canonical Settings API
The canonical settings API stores typed user preferences from the shared
settings contract. Client implementations should discover the server contract
before rendering controls or writing a value; do not keep a separate list of
keys, scopes, enum members, or defaults.
All paths below are relative to `/api/v1`.
## Contract discovery
| Method and path | Purpose |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /settings/manifest` | Public client projection of the current manifest. Supports `If-None-Match`. |
| `GET /settings/capability` | Contract API version, revision, remote scopes, supported client families, and batch/write capabilities. |
`/settings/contract` and `/settings/contract/capabilities` are equivalent
aliases. A client whose vendored contract is newer than the advertised server
revision must hide definitions and features introduced after that revision.
Navigation shortcut mutation controls additionally require
`supports_atomic_shortcuts: true`. Revision-5 customization also requires
`supports_batched_effective: true` and `supports_idempotent_writes: true` so
batched resolution and replayed writes have the semantics the clients depend
on. Any missing flag fails closed.
## Request identity headers
Authenticated settings routes use the active account and profile from the
normal Silo session. The contextual headers below identify which client is
resolving or writing an override.
| Header | When required | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Profile-Id` | All `/settings/values` routes | Active household profile. |
| `X-Silo-Device-Id` | A `profile_device` explicit request, or an effective request containing a key that permits `profile_device` | Stable exact-device identity. |
| `X-Silo-Client-Family` | Required for a `profile_client` explicit request; optional on effective reads | Closed like-client identity: `tv`, `mobile`, `tablet`, `desktop`, or `web`. A valid value includes the like-client layer; absence skips it. |
| `X-Silo-Mutation-Id` | Optional on `PUT` | Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict. |
Client family is intentionally independent of the free-form
`X-Silo-Device-Platform` display metadata. The server never guesses one from
the other. Send the exact lower-case family:
| Client | Family |
| ----------------------- | --------- |
| tvOS or Android TV | `tv` |
| iPhone or Android phone | `mobile` |
| iPad or Android tablet | `tablet` |
| macOS | `desktop` |
| Browser | `web` |
## Remote scopes
Every stored value has exactly one identity. Context fields not named by the
selected scope must be absent.
| Scope | Identity after account |
| ----------------- | ----------------------------- |
| `account` | none |
| `profile` | `profile_id` |
| `profile_client` | `profile_id`, `client_family` |
| `profile_device` | `profile_id`, `device_id` |
| `profile_library` | `profile_id`, `library_id` |
| `profile_series` | `profile_id`, `series_id` |
The manifest's `allowed_scopes` decides where each key may be written, and its
`resolution_order` decides precedence. `profile_client` values roam only among
like clients. For example, a TV value applies to tvOS and Android TV but not to
a phone or browser.
## Explicit values
Use explicit endpoints to edit or clear one scope, without resolving inherited
values:
- `GET /settings/values?keys=<csv>&scope=<scope>` returns every requested key
with `is_set`; unset rows remain in the response.
- `GET /settings/values/{key}?scope=<scope>` returns one stored value or `404`.
- `PUT /settings/values/{key}?scope=<scope>` accepts `{"value": <typed JSON>}`.
- `DELETE /settings/values/{key}?scope=<scope>` removes the row so inheritance
applies again.
`library_id` and `series_id` are query parameters for their matching scopes.
An explicitly managed device may be named with `device_id`, subject to the
profile/device authorization checks. The self-service `profile_client` scope
takes its family only from `X-Silo-Client-Family`.
Example: share a TV menu between tvOS and Android TV clients.
```http
PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Client-Family: tv
Content-Type: application/json
{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}
```
Stored-value responses include `client_family` when the source or explicit row
is at `profile_client`.
### Atomic navigation shortcuts
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
web clients. Self-service clients must mutate one destination at a time instead
of replacing that shared document:
```http
PUT /api/v1/settings/values/nav.shortcuts/item
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
Content-Type: application/json
{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}
```
The item is one member of `navigation-shortcuts.json`: a library, section, or
collection (whose `collection_id` is a string and whose `library_id` is
optional). Identity is exactly the schema's semantic identity:
- library: `type + library_id`
- section: `type + library_id + section_id`
- collection: `type + optional library_id + collection_id`
`present: true` appends an absent item or refreshes the label of an existing
identity in place without reordering it. `present: false` removes that identity;
the supplied label is validated but ignored for matching. The server atomically
rebases on concurrent edits, enforces the full schema and 256-item cap, and
returns the normal stored-value object containing the complete resulting
`value`, row `revision`, and `updated_at`. An already-satisfied operation is a
successful no-op and does not increment the revision. Removing from a catalog
that has never been stored returns `{"items":[]}` at revision `0` with no
`updated_at`.
Retry with the same `X-Silo-Mutation-Id`. A recorded retry returns the original
response with `X-Silo-Idempotent-Replay: true`; reusing the id for a different
semantic operation returns `409 mutation_id_conflict`. The mutation ID is
serialized before the setting write, and the setting plus its replay receipt
commit in one database transaction, so concurrent reuse cannot apply twice and
a crash cannot persist only one half. A rare exhausted
contention loop returns retryable `409 setting_update_conflict`. Malformed
envelopes or unknown envelope fields return `400 bad_request`, while item
schema failures (including unknown item fields) and cap failures return
`400 invalid_value`.
Ordinary session `PUT` and `DELETE` at
`/settings/values/nav.shortcuts?scope=profile` are rejected with
`400 atomic_update_required` so a whole-document mutation cannot erase
concurrent item edits or reset the row revision. The admin endpoint retains
whole-document `PUT` for explicit repair work, but rejects physical `DELETE`
for the same revision-history reason. An admin clears the catalog with
`{"value":{"items":[]}}`, which advances the row revision, or replaces it with
another validated document.
## Effective values
- `GET /settings/values/effective?keys=<csv>` resolves several keys for the
request profile, device, and client family. Omitting `keys` resolves all
remote definitions.
- `POST /settings/values/effective` resolves a bounded list of content contexts
in one store read. Use it for grids or lists rather than issuing one request
per item.
An effective request must include the device header when any requested
definition permits an exact-device override. The family header is optional for
backward compatibility: absence skips the `profile_client` layer, while a
non-empty invalid family is rejected. First-party clients should send their
family so like-device preferences participate in resolution. Each response
includes its source scope and source context; `client_family` is included for a
family-scoped winner.
## Admin projection
Admin routes are mounted behind the normal acting-admin authorization:
- `GET /admin/users/{id}/settings/values` lists every stored row across all
scopes.
- `PUT /admin/users/{id}/settings/values/{key}?scope=<scope>` writes through the
same contract validation and normalization as the self-service endpoint.
- `DELETE /admin/users/{id}/settings/values/{key}?scope=<scope>` clears the
selected row.
Admin requests name profile/context identity with query parameters because the
target user is not the admin's active session. In particular,
`scope=profile_client` requires both `profile_id` and `client_family` query
parameters; it does not use `X-Silo-Client-Family`.
```http
PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
Authorization: Bearer <admin-token>
Content-Type: application/json
{"value":{"poster_size":"large","caption":"artwork"}}
```
The five accepted `client_family` values are also returned by the capability
endpoint so admin tooling does not need to invent them.
@@ -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.
@@ -289,9 +289,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)
@@ -416,6 +416,7 @@ The remote scopes are:
|---|---|---|
| `account` | `(user_id)` | Same for every profile and signed-in client on the account. |
| `profile` | `(user_id, profile_id)` | Roams with one profile. |
| `profile_client` | `(user_id, profile_id, client_family)` | Roams between like clients: `tv`, `mobile`, `tablet`, `desktop`, or `web`. |
| `profile_device` | `(user_id, profile_id, device_id)` | Override for one profile on one device identity. |
| `profile_library` | `(user_id, profile_id, library_id)` | Content preference for one library. |
| `profile_series` | `(user_id, profile_id, series_id)` | Content preference for one series. |
@@ -423,6 +424,10 @@ The remote scopes are:
`client_local` definitions use a single logical `client_local` scope and are never addressed by the
server values API.
Clients send the family explicitly as `X-Silo-Client-Family`; the server never infers it from the
free-form `X-Silo-Device-Platform` registry label. tvOS and Android TV use `tv`, phones use
`mobile`, tablets use `tablet`, native desktop clients use `desktop`, and browsers use `web`.
All remote mutations carry their complete identity explicitly. The server authorizes that the
profile, library, series, and device belong to the authenticated user. A queued operation must not
derive its profile or server from whichever account happens to be active when the retry runs.
@@ -448,6 +453,8 @@ Examples:
|---|---|
| Audio/subtitle selection | series → library → device → profile → default |
| Playback behavior with device override | device → profile → default |
| Family-synchronized presentation | device → client family → profile → default |
| Family-synchronized navigation | device → client family → default |
| Device playback capability | device → default |
| Account UI preference | account → default |
| Client-local OS behavior | local value → default |
@@ -625,8 +632,10 @@ to check compatibility without transferring the manifest body.
- Returns the explicit value and revision at exactly one requested scope; it does not resolve
fallbacks.
- Context parameters are required by scope: `profile_id`, `device_id`, `library_id`, or `series_id`
as defined by the identity table above.
- Context parameters are required by scope: `profile_id`, `client_family`, `device_id`,
`library_id`, or `series_id` as defined by the identity table above. Self-service
`profile_client` requests take client family from `X-Silo-Client-Family`; admin explicit-value
routes name it with the `client_family` query parameter.
- An unset value is represented as `is_set: false` with no `value` member, never as an empty string
or JSON `null`.
- Settings screens use this endpoint to show profile defaults and device overrides independently.
@@ -637,7 +646,8 @@ to check compatibility without transferring the manifest body.
`GET /api/v1/settings/values/effective?keys=<comma-separated-keys>`
- Requires the active profile and device identity headers for definitions that can resolve those
scopes.
scopes. `X-Silo-Client-Family` is optional for effective reads so older callers remain compatible:
absence skips `profile_client`, a valid value includes it, and a non-empty invalid value is rejected.
- Rejects unknown keys rather than fabricating defaults.
- Returns native typed values, resolution source, source context, definition revision,
`updated_at`, and any policy constraint.
@@ -789,6 +799,7 @@ CREATE TABLE user_setting_values (
key text NOT NULL,
scope text NOT NULL,
profile_id text,
client_family text,
device_id text,
library_id integer,
series_id text,
@@ -796,13 +807,15 @@ CREATE TABLE user_setting_values (
revision bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
CHECK (
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
)
);
```
@@ -819,6 +832,8 @@ CREATE UNIQUE INDEX user_setting_values_account_uq
ON user_setting_values (user_id, key) WHERE scope = 'account';
CREATE UNIQUE INDEX user_setting_values_profile_uq
ON user_setting_values (user_id, profile_id, key) WHERE scope = 'profile';
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
ON user_setting_values (user_id, profile_id, client_family, key) WHERE scope = 'profile_client';
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
ON user_setting_values (user_id, profile_id, device_id, key) WHERE scope = 'profile_device';
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
@@ -949,8 +964,19 @@ client. The following decisions resolve today's duplicate semantics:
| Date/time format | remote: **profile** | Existing account rows are copied to every profile. |
| Search media scope | remote: profile | Preserve strict enums. |
| `ui.library_page_state` | remote: profile_device | Keep navigation state tied to one profile/device. |
| `nav.primary_menu` | remote: profile_client, profile_device | Family menu ordering/visibility with an optional exact-device escape hatch; null means use the family-native baseline. |
| `nav.shortcuts` | remote: profile | Profile-wide catalog of library, section, and collection pins; seed convertible legacy `ui.sidebar_pins` without deleting the old row. |
| `ui.card_presentation` | remote: profile, profile_client, profile_device | Semantic size/caption presets; device → family → profile → default. |
| OS caption mirroring, platform decoder diagnostics, temporary sleep timers | client_local or private `local.*` | Production caption-mirroring UI is contract-known local; diagnostics/timers remain private local. |
Navigation destinations are unique by semantic identity, not by the complete JSON object: built-ins
use `destination`, libraries use `library_id`, sections use `(library_id, section_id)`, and collections
use `(optional library_id, collection_id)`. Changing a label therefore cannot create a duplicate menu
entry. Labels and string target ids must contain a non-whitespace character; labels are bounded to
256 characters and target ids to 128. The JSON schemas document these rules and the canonical
validator enforces the cross-item identity check that JSON Schema `uniqueItems` cannot express by
itself.
### Appearance belongs to the profile, not the account
Theme, text scale, contrast, custom CSS, and date/time format are stored today in `user_settings`
@@ -0,0 +1,301 @@
# Plugin-backed watch sync providers
## Status
Implemented design for the expanded `silo-plugin-sdk` v0.13
`watch_sync_provider.v1` contract. The server uses the immutable SDK commit
associated with the v0.13 pull request until that version is released. This
specification does not introduce a second watch synchronization pipeline.
Plugin-backed providers adapt the public plugin RPC contract to Silo's existing
`watchsync` service, connection repository, history exports, scrobble sessions,
rate-limit handling, list reconciliation, and scheduled reconciliation.
## Goals
- Allow an enabled plugin capability to appear as a profile-scoped watch
provider.
- Reuse Silo's encrypted watch-provider connections for personal credentials.
- Support API-key and device-code connection flows.
- Import and export watched state, progress, favorites, and watchlists according
to each plugin's advertised capabilities.
- Deliver explicit watched/unwatched and list operations plus live playback
start, pause, and stop events.
- Preserve the existing durable history-export reconciliation path.
- Preserve complete provider credential shapes, including token type, scopes,
expiry, and opaque secret attributes.
- Supply declared installation configuration to provider calls without mixing
it with profile credentials.
- Keep provider plugins stateless and prevent credentials from entering generic
plugin configuration.
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
## Non-goals
- Authorization-code OAuth UI and callback routes.
- A second outbox or worker implementation.
- Allowing plugins to persist or own personal provider credentials.
- Removing built-in Trakt, Simkl, or MDBList providers in the same change. They
remain available while equivalent plugins are built and connections are
migrated deliberately.
Plugins may implement any subset of the state families and operations. A
provider such as Floppy can advertise watched import/export, progress import,
and live scrobbling without claiming favorites, watchlists, or unwatch support.
Silo exposes only executable capabilities from the descriptor.
## SDK boundary
Plugins advertise `watch_sync_provider.v1` with a typed descriptor containing:
- supported authentication methods;
- import/export capabilities;
- supported media types and external-ID namespaces;
- maximum apply batch size.
The host uses `WatchSyncProvider` for the stable provider operations and a
separate `WatchSyncDeviceAuthorizationService` for device-code start and poll.
The split keeps direct Go implementations of the released v0.12 provider
interface source-compatible. Together the services support:
- device authorization `Start` and `Poll`;
- `ExchangeAPIKey`;
- `RefreshCredentials`;
- `GetAccount`;
- `ApplyEvents` for watched, unwatched, favorite, watchlist, and live scrobble
operations;
- `ListRemoteState` for watched, progress, favorite, and watchlist state.
Authorization-code descriptors remain valid SDK contracts, but the host does
not register a plugin that offers only authorization-code authentication until
the public watch-provider callback flow exists.
Every provider RPC receives the complete current credentials and relevant
installation configuration only for that call. Plugins must not persist or log
credentials. Generic `Runtime.Configure` data is not a personal credential
store. Returned credentials are an authoritative replacement, and the host
persists them before interpreting results, pages, or faults from the same RPC.
## Discovery and lifecycle
At startup and after plugin lifecycle changes, Silo:
1. lists enabled, non-builtin plugin installations;
2. selects `watch_sync_provider.v1` capabilities;
3. decodes and validates their typed descriptors;
4. constructs thin `watchsync.Provider` adapters;
5. atomically replaces the registry's previous plugin-backed providers while
preserving built-ins.
Provider keys bind persisted credentials to the installation and capability
(`plugin:<installation-id>:<capability-id>`), while RPC routing continues to use
the manifest capability ID. This prevents a subsequently installed plugin from
inheriting credentials merely by reusing another plugin's capability ID.
Provider keys must not shadow built-ins or other plugin providers. A conflicting
lifecycle reload fails without partially replacing the registry. Invalid,
unreadable, or currently unsupported descriptors are logged and omitted, and
the successfully discovered set still replaces the previous plugin providers.
This fail-closed behavior prevents a disabled or removed plugin from remaining
registered because another installation could not be decoded.
## Existing watchsync interfaces
The adapter implements the existing interfaces corresponding to the
descriptor's advertised capabilities:
- `Provider`;
- `AuthProvider`, `APIKeyAuthProvider`, and device authorization;
- watched import/export and unwatch export;
- progress import;
- favorite and watchlist import/export/removal, including ordered watchlists;
- `Scrobbler` and `OrderedScrobbler` for start, pause, and stop delivery.
`LocalPlay` and `ScrobbleEvent` values become rich desired-state plugin events.
The host supplies movie and series external IDs, season and episode numbers,
local history identity, playback identity, timestamps, and completion values.
The plugin never needs a Silo API key or a callback into catalog HTTP routes.
## Authentication and credentials
The existing watch-provider API-key connection route passes the entered token
to `ExchangeAPIKey`. Device-code providers use the existing start and poll
routes. The plugin validates or exchanges the credential and returns normalized
credentials plus provider account identity.
Credential refresh remains host-initiated and serialized by the existing
watchsync service. The host encrypts an authoritative credential bundle
containing access token, refresh token, expiry, token type, scopes, and opaque
secret attributes. Legacy access/refresh columns remain readable during the
migration. Device codes are also encrypted at rest. A pending device poll may
rotate its opaque provider state, interval, or expiry; the host validates and
persists the replacement before another poll. Invalid-credential faults are
persisted using only the plugin's safe message.
Authorization-code OAuth can be added separately to the existing watch-
provider service after its client-secret storage and callback-state design are
reviewed.
## Watched export flow
### Explicit mark watched
The existing manual watch path records leaf history entries and invokes
`HandleLocalWatchEvent`. The existing history-export table is populated before
provider I/O. The plugin adapter sends each pending `LocalPlay` as an absolute
`MARK_WATCHED` desired-state event. The interactive path sends one bounded
plugin batch; additional queued events are delivered by scheduled
reconciliation.
### Playback completion
The existing playback path invokes `ScrobbleStop` with rich identity. For a
completed event and a provider that exports watched state, Silo first upserts a
pending history-export record, then dispatches the immediate stop operation.
On successful immediate delivery, the history export is marked
`satisfied_by_scrobble`. If immediate delivery fails or the process exits, the
existing scheduled history reconciliation retries the same local history. A
compatibility playback path without a local history ID retains its own durable
terminal event until the confirmed stop succeeds. The plugin operation is
convergent, so an uncertain duplicate cannot lower progress or increment a play
counter.
Start, pause, and incomplete stop calls are no-ops for providers that only
advertise watched export. Providers advertising live scrobbling receive all
three operations with playback position and duration.
If the remote stop succeeds but the local history-export transition fails, the
stop is not dispatched again. The scrobble session records a durable pending
history-reconciliation marker, and the sweeper retries only the local database
transition.
## Remote state import and lists
The scheduled sync path requests one state family at a time. Each request
contains the stable cursor for that family and an opaque page token. The host:
1. persists any credentials returned by the page;
2. discards the page and retains its cursor on a top-level fault;
3. applies valid items idempotently through existing watch-state and list
services;
4. advances the page token until the plugin reports no next page;
5. commits the family cursor only after the traversal succeeds.
Incremental list pages do not imply deletion of absent items; they use explicit
provider-key tombstones for removals. Complete snapshots may reconcile removals.
Ordered watchlists preserve the plugin's list position and require complete
snapshots because an incremental subset cannot define positions relative to
omitted items. Traversals are bounded by both page and item count before their
durable cursor advances.
Progress items without a valid provider timestamp are rejected rather than
being stamped with host time.
Imported history is tagged with the stable plugin provider key, not a generic
import source. Echo suppression therefore filters only export back to the
originating plugin; the same local event remains eligible for other connected
providers.
## Provider configuration
Manifest-declared global configuration is resolved per installation before each
provider call. Public fields are sent in `values`; secret and undeclared fields
are sent in `secret_values`. Nested manifest fields use the stable
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
remain separate and encrypted in watch-provider connection storage.
## Apply result handling
Each event has a stable ID. The adapter requires a matching result and maps it
as follows:
- `APPLIED` and `NO_CHANGE`: export sent;
- `REJECTED`: permanent not-found/unmappable export;
- `RETRY` with a temporary fault: failed export eligible for existing retry handling;
- top-level or per-event rate limit: existing `RateLimitedError` and account
deferral, after committing any successfully applied events from the same RPC;
- missing or unknown result: failed export eligible for retry, unless the same
RPC reported a rate limit, in which case unmentioned events remain pending;
- plugin transport, process availability, and top-level temporary faults:
remain pending without consuming a per-event attempt.
A top-level invalid-credential fault records a typed safe connection error.
A dedicated reconnect-required connection state is deferred as described
above. Plugin messages persisted by Silo must be explicitly safe for operator
display and must not contain response bodies or secrets. The host redacts the
connection credentials, normalizes control characters and whitespace, and
bounds the displayed message length; transport details are replaced with
host-owned text.
## Ordering and batching
Immediate scrobbles are ordered by connection and stable series identity using
the existing ordered-scrobble queue. History reconciliation is ordered by local
watch timestamp.
Each synchronization run sends at most one plugin RPC, bounded by the
descriptor's maximum batch size. The existing service commits every per-event
result before a later run requests the next pending batch. Failures increment
attempts only for events included in that RPC. This avoids partial-success loss,
retry exhaustion within one run, and unbounded cumulative RPC time.
## Reliability boundaries
This design deliberately reuses the reliability guarantees currently accepted
for built-in providers:
- encrypted profile-scoped connections;
- durable local watch history;
- durable history-export rows;
- immediate scrobble sessions;
- scheduled reconciliation;
- bounded retry attempts and safe recorded errors;
- provider/account rate-limit deferral.
It does not claim multi-node leased delivery or infinite retries. Those should
be improved in the shared watchsync pipeline for built-in and plugin providers
together rather than introduced only for plugins.
## Security requirements
- Personal tokens never enter plugin runtime config or user settings.
- Tokens are decrypted only at the provider invocation boundary.
- RPC request values containing credentials are never logged.
- Plugin-returned errors are sanitized before persistence and API display;
transport errors are replaced by a generic message.
- Account/profile scope remains enforced by the existing connection lookup.
- Credential AAD and provider lookup include installation identity, preventing
sequential capability-ID reuse from exposing an old token to a new install.
- Disabled plugins disappear from provider discovery; existing connections are
unavailable until that same installation and capability return.
## Compatibility
The change is additive to `/api/v1`. Existing provider summaries and connection
routes continue to work. A plugin provider appears through the same provider
summary model and uses the existing API-key or device-code connection routes.
No client changes are required because no existing fields or status codes
change. SDK v0.12 plugins remain wire-compatible; descriptors only expose the
capabilities the older contract can execute.
## Validation plan
- capability enforcement in the plugin host client;
- atomic registry replacement and built-in collision tests;
- API-key and device-code authentication, full credential replacement, and
account identity conversion;
- provider configuration routing and secret/public field classification;
- rich movie and episode identity conversion;
- watched, progress, favorite, and watchlist import pagination and cursor
behavior;
- unwatch, favorite, watchlist, and ordered-list event conversion;
- provider-specific import source keys and cross-provider export behavior;
- completed playback persistence before plugin I/O;
- `satisfied_by_scrobble` transition after success;
- durable retry of a failed local post-stop history transition without another
remote stop;
- immediate failure followed by scheduled history reconciliation;
- typed rate-limit and invalid-credential handling;
- complete credential encryption/decryption and legacy fallback;
- plugin disable/re-enable lifecycle reload;
- no regression in existing watchsync, pluginhost, and plugin service tests;
- `go test`, targeted race tests, `go vet`, lint, formatting checks, and local
path verification before proposing a merge request.
@@ -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`;
+1 -1
View File
@@ -109,7 +109,7 @@ require (
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.12.0
github.com/Silo-Server/silo-plugin-sdk v0.13.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
+2 -2
View File
@@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.13.0 h1:BXf4cuNbOIIsrVJrRdlQnbc1m04X6lcPSZI+AFLMeK0=
github.com/Silo-Server/silo-plugin-sdk v0.13.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestPlaybackHandlerTranscodeRuntimeConfigIncludesSegmentRetention(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.PlaybackConfig = func() config.PlaybackConfig {
return config.PlaybackConfig{SegmentRetentionSeconds: 300}
}
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
t.Fatalf("configured segment retention = %d, want 300", got)
}
}
@@ -0,0 +1,127 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
func TestProxyToTranscodeNodeAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
const (
body = "complete segment"
generation = "17"
)
tests := []struct {
name string
rangeHeader string
wantStatus int
wantAck int32
}{
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var acknowledgements atomic.Int32
seenRange := make(chan string, 1)
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
t.Error("segment request omitted proxy marker")
}
seenRange <- r.Header.Get("Range")
w.Header().Set(transcodeproxy.GenerationHeader, generation)
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
case http.MethodPost:
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
}
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}
}))
defer node.Close()
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
req := withPlaybackRouteParam(
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
"session_id",
"public",
)
req.Header.Set("Range", tt.rangeHeader)
rr := httptest.NewRecorder()
handler.proxyToTranscodeNode(rr, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
}
if got := <-seenRange; got != tt.rangeHeader {
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
}
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
t.Fatalf("internal generation leaked downstream: %q", got)
}
if got := acknowledgements.Load(); got != tt.wantAck {
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
}
})
}
}
func TestProxyToTranscodeNodeDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
const body = "complete segment"
var acknowledgements atomic.Int32
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set(transcodeproxy.GenerationHeader, "21")
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
}))
defer node.Close()
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
req := withPlaybackRouteParam(
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
"session_id",
"public",
)
w := &failingProxyResponseWriter{header: make(http.Header), remaining: 5}
handler.proxyToTranscodeNode(w, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
if got := acknowledgements.Load(); got != 0 {
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
}
}
type failingProxyResponseWriter struct {
header http.Header
status int
remaining int
}
func (w *failingProxyResponseWriter) Header() http.Header { return w.header }
func (w *failingProxyResponseWriter) WriteHeader(status int) { w.status = status }
func (w *failingProxyResponseWriter) Write(p []byte) (int, error) {
if w.remaining <= 0 {
return 0, io.ErrClosedPipe
}
n := min(len(p), w.remaining)
w.remaining -= n
return n, io.ErrClosedPipe
}
@@ -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 {
@@ -1,148 +0,0 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
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 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"},
},
}
@@ -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})
+1
View File
@@ -46,6 +46,7 @@ const (
deviceIDHeader = "X-Silo-Device-Id"
deviceNameHeader = "X-Silo-Device-Name"
devicePlatformHeader = "X-Silo-Device-Platform"
clientFamilyHeader = "X-Silo-Client-Family"
)
// ServerSettingReader reads individual keys from the server_settings table.
+8 -1
View File
@@ -23,7 +23,10 @@ import (
// activitylog is an HTTP request-log middleware whose schema has no profile or
// body, so it cannot express "actor P changed key K for profile Q". Persisting
// these needs its own table and migration.
const settingsAuditMsg = "settings changed for another profile"
const (
settingsAuditMsg = "settings changed for another profile"
settingsAuditActionSet = "set"
)
// logComponentKey is the structured-log attribute every handler in this package
// tags itself with.
@@ -37,6 +40,7 @@ type settingsAuditRecord struct {
// actor's own account only on the admin routes, where profile ids alone
// would not say whose settings moved.
TargetUserID int
ClientFamily string
DeviceID string
Key string
Scope string
@@ -67,6 +71,9 @@ func auditSettingsForOther(ctx context.Context, record settingsAuditRecord) {
if record.Scope != "" {
attrs = append(attrs, "scope", record.Scope)
}
if record.ClientFamily != "" {
attrs = append(attrs, "client_family", record.ClientFamily)
}
if record.DeviceID != "" {
attrs = append(attrs, "device_id", record.DeviceID)
}
+566 -91
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
@@ -118,35 +119,99 @@ const fieldValues = "values"
// the request boundary keeps a crafted batch from failing resolution outright.
const maxEffectiveContentIDs = 200
// maxShortcutMutationRetries bounds contention retries while still making a
// normal burst of edits effectively wait-free for clients. Each failed CAS
// means another writer made progress; exhausting this limit is therefore a
// retryable conflict, never permission to overwrite the newer document.
const maxShortcutMutationRetries = 32
const jsonNullLiteral = "null"
const navigationShortcutAtomicUpdateMessage = "Use PUT /settings/values/nav.shortcuts/item to change navigation shortcuts"
var (
errMutationIDConflict = errors.New("setting mutation id conflict")
errMutationReplayRollback = errors.New("setting mutation replay requires rollback")
errMutationTransactionRequired = errors.New("settings store does not support atomic idempotent mutations")
errShortcutMutationContention = errors.New("navigation shortcuts changed too quickly")
errShortcutMutationInvalidValue = errors.New("invalid navigation shortcut value")
)
type idempotentSettingMutationOutcome struct {
result json.RawMessage
stored *userstore.SettingValue
replay bool
changed bool
}
type shortcutMutationStore interface {
GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error)
CompareAndSetSettingValue(
context.Context,
userstore.SettingIdentity,
json.RawMessage,
int64,
) (*userstore.SettingValue, error)
}
// settingValueResponse is one explicit stored value.
type settingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type navigationShortcutMutationRequest struct {
Item json.RawMessage `json:"item"`
Present *bool `json:"present"`
}
type navigationShortcutDocument struct {
Items []navigationShortcutItem `json:"items"`
}
// navigationShortcutItem mirrors navigation-shortcuts.json. LibraryID is a
// pointer because its presence is part of collection identity: a global
// collection and a library-specific collection with the same collection_id
// are different destinations.
type navigationShortcutItem struct {
Type string `json:"type"`
LibraryID *int `json:"library_id,omitempty"`
SectionID string `json:"section_id,omitempty"`
CollectionID string `json:"collection_id,omitempty"`
Label string `json:"label"`
}
type navigationShortcutIdentity struct {
Type string
LibraryID int
HasLibraryID bool
SectionID string
CollectionID string
}
// explicitSettingValueResponse is one entry from the collection GET. Unset is
// represented explicitly rather than as a 404 so a settings screen can fetch
// several profile defaults or device overrides in one request.
type explicitSettingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
IsSet bool `json:"is_set"`
Value json.RawMessage `json:"value,omitempty"`
Revision int64 `json:"revision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
IsSet bool `json:"is_set"`
Value json.RawMessage `json:"value,omitempty"`
Revision int64 `json:"revision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// effectiveSettingValueResponse is one resolved value plus where it came from.
@@ -176,18 +241,20 @@ type effectiveSettingValueResponse struct {
// Scope locates the row the value came from, so a client can offer a reset
// against exactly that scope. Empty for a contract default.
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
type effectiveSourceContextResponse struct {
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
ClientFamily string `json:"client_family,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
// HandleGetContract serves the public manifest.
@@ -235,12 +302,21 @@ func (h *SettingValuesHandler) HandleGetCapabilities(w http.ResponseWriter, r *h
"scopes": []string{
string(settingscontract.ScopeAccount),
string(settingscontract.ScopeProfile),
string(settingscontract.ScopeProfileClient),
string(settingscontract.ScopeProfileDevice),
string(settingscontract.ScopeProfileLibrary),
string(settingscontract.ScopeProfileSeries),
},
"client_families": []string{
string(settingscontract.ClientFamilyTV),
string(settingscontract.ClientFamilyMobile),
string(settingscontract.ClientFamilyTablet),
string(settingscontract.ClientFamilyDesktop),
string(settingscontract.ClientFamilyWeb),
},
"supports_batched_effective": true,
"supports_idempotent_writes": true,
"supports_atomic_shortcuts": true,
})
}
@@ -306,6 +382,9 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
switch identity.Scope {
case settingscontract.ScopeProfile:
query.ProfileIDs = []string{identity.ProfileID}
case settingscontract.ScopeProfileClient:
query.ProfileIDs = []string{identity.ProfileID}
query.ClientFamily = identity.ClientFamily
case settingscontract.ScopeProfileDevice:
query.ProfileIDs = []string{identity.ProfileID}
query.DeviceID = identity.DeviceID
@@ -332,7 +411,8 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
for _, key := range keys {
entry := explicitSettingValueResponse{
Key: key, Scope: string(identity.Scope), ProfileID: identity.ProfileID,
DeviceID: identity.DeviceID, LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
ClientFamily: string(identity.ClientFamily), DeviceID: identity.DeviceID,
LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
}
if value, exists := byKey[key]; exists {
entry.IsSet = true
@@ -363,9 +443,333 @@ func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Req
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
navigationShortcutAtomicUpdateMessage)
return
}
h.setValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
}
// HandleSetNavigationShortcut applies one desired-state edit to the shared
// profile shortcut catalog. Unlike the generic whole-document PUT, two clients
// adding different destinations cannot overwrite one another: the handler
// rebases after an internal compare-and-set conflict until its semantic edit
// lands on the newest document.
func (h *SettingValuesHandler) HandleSetNavigationShortcut(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
shortcutStore, ok := store.(shortcutMutationStore)
if !ok {
writeError(w, http.StatusInternalServerError, "internal_error",
"This settings store does not support atomic shortcut updates")
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: profileID,
}
if err := identity.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
def, ok := h.definitionFor(w, identity.Key)
if !ok {
return
}
var body navigationShortcutMutationRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request",
"Body must be {\"item\": {…}, \"present\": true|false}")
return
}
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Body must be a single JSON document")
return
}
if body.Present == nil {
writeError(w, http.StatusBadRequest, "bad_request", "present is required")
return
}
item, err := normalizeNavigationShortcutItem(def, body.Item)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
return
}
requestHash := hashNavigationShortcutMutation(identity, item, *body.Present)
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
var stored *userstore.SettingValue
var idempotentResult json.RawMessage
var changed bool
if mutationID == "" {
stored, changed, err = mutateNavigationShortcut(
r.Context(), shortcutStore, def, identity, item, *body.Present)
} else {
var outcome idempotentSettingMutationOutcome
outcome, err = runIdempotentSettingMutation(
r.Context(), store, mutationID, requestHash,
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
return mutateNavigationShortcut(r.Context(), writer, def, identity, item, *body.Present)
},
)
if err == nil && outcome.replay {
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, outcome.result)
return
}
stored, changed = outcome.stored, outcome.changed
idempotentResult = outcome.result
}
if err != nil {
switch {
case errors.Is(err, errMutationIDConflict):
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
case errors.Is(err, errShortcutMutationContention):
writeError(w, http.StatusConflict, "setting_update_conflict",
"Navigation shortcuts changed too quickly; retry this mutation")
case errors.Is(err, errShortcutMutationInvalidValue):
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
case errors.Is(err, userstore.ErrInvalidSettingIdentity),
errors.Is(err, userstore.ErrInvalidSettingValue):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store navigation shortcuts")
}
return
}
response := settingValueToResponse(*stored)
if changed {
publishUserSettingsEvent(r.Context(), h.EventsHub,
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: settingsAuditActionSet,
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: identity.ProfileID,
TargetUserID: apimw.GetUserID(r.Context()),
Key: identity.Key,
Scope: string(identity.Scope),
})
}
if idempotentResult != nil {
writeRawJSON(w, http.StatusOK, idempotentResult)
} else {
writeJSON(w, http.StatusOK, response)
}
}
func normalizeNavigationShortcutItem(
def *settingscontract.Definition,
item json.RawMessage,
) (navigationShortcutItem, error) {
if len(item) == 0 {
return navigationShortcutItem{}, errors.New("item is required")
}
raw, err := json.Marshal(struct {
Items []json.RawMessage `json:"items"`
}{Items: []json.RawMessage{item}})
if err != nil {
return navigationShortcutItem{}, fmt.Errorf("encoding shortcut: %w", err)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return navigationShortcutItem{}, err
}
var document navigationShortcutDocument
if err := json.Unmarshal(normalized, &document); err != nil || len(document.Items) != 1 {
return navigationShortcutItem{}, errors.New("shortcut did not normalize to one item")
}
return document.Items[0], nil
}
func applyNavigationShortcutMutation(
def *settingscontract.Definition,
current *userstore.SettingValue,
item navigationShortcutItem,
present bool,
) (json.RawMessage, bool, error) {
document := navigationShortcutDocument{Items: []navigationShortcutItem{}}
if current != nil {
if err := json.Unmarshal(current.Value, &document); err != nil {
return nil, false, fmt.Errorf("decoding stored navigation shortcuts: %w", err)
}
}
target := item.identity()
match := -1
for index, candidate := range document.Items {
if candidate.identity() == target {
match = index
break
}
}
if present {
if match >= 0 {
if document.Items[match].equal(item) {
return current.Value, false, nil
}
document.Items[match] = item
} else {
document.Items = append(document.Items, item)
}
} else {
if match < 0 {
if current == nil {
return json.RawMessage(`{"items":[]}`), false, nil
}
return current.Value, false, nil
}
document.Items = append(document.Items[:match], document.Items[match+1:]...)
}
raw, err := json.Marshal(document)
if err != nil {
return nil, false, fmt.Errorf("encoding navigation shortcuts: %w", err)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return nil, false, fmt.Errorf("%w: %w", errShortcutMutationInvalidValue, err)
}
return normalized, true, nil
}
func (item navigationShortcutItem) identity() navigationShortcutIdentity {
identity := navigationShortcutIdentity{
Type: item.Type, SectionID: item.SectionID, CollectionID: item.CollectionID,
}
if item.LibraryID != nil {
identity.LibraryID = *item.LibraryID
identity.HasLibraryID = true
}
return identity
}
func (item navigationShortcutItem) equal(other navigationShortcutItem) bool {
return item.identity() == other.identity() && item.Label == other.Label
}
func mutateNavigationShortcut(
ctx context.Context,
store shortcutMutationStore,
def *settingscontract.Definition,
identity userstore.SettingIdentity,
item navigationShortcutItem,
present bool,
) (*userstore.SettingValue, bool, error) {
for attempt := 0; attempt < maxShortcutMutationRetries; attempt++ {
current, err := store.GetSettingValue(ctx, identity)
if err != nil {
return nil, false, fmt.Errorf("reading navigation shortcuts: %w", err)
}
next, changed, err := applyNavigationShortcutMutation(def, current, item, present)
if err != nil {
return nil, false, err
}
if !changed {
if current != nil {
return current, false, nil
}
return &userstore.SettingValue{
SettingIdentity: identity,
Value: json.RawMessage(`{"items":[]}`),
}, false, nil
}
expectedRevision := int64(0)
if current != nil {
expectedRevision = current.Revision
}
stored, err := store.CompareAndSetSettingValue(ctx, identity, next, expectedRevision)
if errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
continue
}
if err != nil {
return nil, false, err
}
return stored, true, nil
}
return nil, false, errShortcutMutationContention
}
func runIdempotentSettingMutation(
ctx context.Context,
store userstore.UserStore,
mutationID string,
requestHash string,
mutate func(userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error),
) (idempotentSettingMutationOutcome, error) {
transactioner, ok := store.(userstore.SettingMutationTransactioner)
if !ok {
return idempotentSettingMutationOutcome{}, errMutationTransactionRequired
}
var outcome idempotentSettingMutationOutcome
err := transactioner.WithSettingMutationTransaction(ctx, mutationID,
func(writer userstore.SettingMutationWriter) error {
prior, err := writer.GetSettingMutation(ctx, mutationID)
if err != nil {
return fmt.Errorf("checking setting mutation: %w", err)
}
if prior != nil {
if prior.RequestHash != requestHash {
return errMutationIDConflict
}
outcome.result = slices.Clone(prior.Result)
outcome.replay = true
return nil
}
stored, changed, err := mutate(writer)
if err != nil {
return err
}
response := settingValueToResponse(*stored)
result, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("encoding setting mutation receipt: %w", err)
}
record, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: result,
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
if err != nil {
return fmt.Errorf("recording setting mutation: %w", err)
}
if !inserted {
if record.RequestHash != requestHash {
return errMutationIDConflict
}
outcome.result = slices.Clone(record.Result)
outcome.replay = true
// A legacy writer could have inserted between the initial read and
// this insert. Roll back our setting write before serving its receipt.
return errMutationReplayRollback
}
outcome.result = slices.Clone(record.Result)
outcome.stored = stored
outcome.changed = changed
return nil
})
if errors.Is(err, errMutationReplayRollback) {
return outcome, nil
}
return outcome, err
}
// setValueAt is the write path shared by the session route and the admin
// route: validation, normalization, idempotency and the change event are one
// implementation regardless of who addresses the store. eventUserID names the
@@ -413,27 +817,34 @@ func (h *SettingValuesHandler) setValueAt(
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
var requestHash string
if mutationID != "" {
requestHash = hashMutationRequest(identity, normalized)
prior, err := store.GetSettingMutation(r.Context(), mutationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check the mutation id")
var stored *userstore.SettingValue
var idempotentResult json.RawMessage
if mutationID == "" {
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
} 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 errors.Is(mutationErr, errMutationIDConflict) {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
if prior != nil {
if prior.RequestHash != requestHash {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
if mutationErr != nil {
err = mutationErr
} else if outcome.replay {
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, prior.Result)
writeRawJSON(w, http.StatusOK, outcome.result)
return
} else {
stored = outcome.stored
idempotentResult = outcome.result
}
}
stored, err := store.UpsertSettingValue(r.Context(), identity, normalized)
if err != nil {
if errors.Is(err, userstore.ErrInvalidSettingIdentity) ||
errors.Is(err, userstore.ErrInvalidSettingValue) {
@@ -445,14 +856,6 @@ func (h *SettingValuesHandler) setValueAt(
}
response := settingValueToResponse(*stored)
if mutationID != "" {
// Recorded only now, after the write landed: a receipt written for a
// failed upsert would turn the client's retry of a 500 into a silent
// 200 replay of a write that never happened. Storing the actual
// response also makes the replay byte-identical — revision and
// updated_at included — rather than a reconstruction of the input.
h.recordMutation(r, store, mutationID, requestHash, response)
}
acting := actingProfileID(r.Context())
if identity.Scope == settingscontract.ScopeProfileDevice {
// A device that only ever writes canonically must still appear in
@@ -472,15 +875,20 @@ func (h *SettingValuesHandler) setValueAt(
publishUserSettingsEvent(r.Context(), h.EventsHub,
eventUserID, identity.ProfileID, identity.Key, string(identity.Scope))
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: "set",
Action: settingsAuditActionSet,
ActorProfileID: acting,
TargetProfileID: identity.ProfileID,
TargetUserID: eventUserID,
ClientFamily: string(identity.ClientFamily),
DeviceID: identity.DeviceID,
Key: identity.Key,
Scope: string(identity.Scope),
})
writeJSON(w, http.StatusOK, response)
if idempotentResult != nil {
writeRawJSON(w, http.StatusOK, idempotentResult)
} else {
writeJSON(w, http.StatusOK, response)
}
}
// registerWritingDevice refreshes the device registry from the request's
@@ -531,6 +939,11 @@ func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
navigationShortcutAtomicUpdateMessage)
return
}
h.deleteValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
}
@@ -557,6 +970,7 @@ func (h *SettingValuesHandler) deleteValueAt(
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: identity.ProfileID,
TargetUserID: eventUserID,
ClientFamily: string(identity.ClientFamily),
DeviceID: identity.DeviceID,
Key: identity.Key,
Scope: string(identity.Scope),
@@ -608,6 +1022,11 @@ func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http
LibraryIDs: parseIntCSV(r.URL.Query().Get("library_ids")),
SeriesIDs: splitCSV(r.URL.Query().Get("series_ids")),
}
if family, needed, ok := h.clientFamilyForKeys(w, r, keys); !ok {
return
} else if needed {
rc.ClientFamily = family
}
// A device-settings screen resolves what some *other* device sees, so this
// read accepts the same explicit identity the write path does, under the
@@ -714,6 +1133,10 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
deviceID := deviceMetadataFromRequest(r).DeviceID
clientFamily, familyNeeded, ok := h.clientFamilyForKeys(w, r, keys)
if !ok {
return
}
if deviceID == "" {
for _, key := range keys {
if def, exists := h.contract.Lookup(key); exists && def.AllowsScope(settingscontract.ScopeProfileDevice) {
@@ -749,6 +1172,9 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
return
}
rc := settingsresolve.Context{ProfileID: profileID, DeviceID: deviceID}
if familyNeeded {
rc.ClientFamily = clientFamily
}
if libraryID > 0 {
rc.LibraryIDs = []int{libraryID}
contentIDs++
@@ -862,28 +1288,6 @@ func (h *SettingValuesHandler) constraintsFor(r *http.Request) settingsresolve.C
return settingsresolve.Constraints{policyInputMaxPlaybackQuality: limit}
}
// recordMutation stores the idempotency receipt after a successful write. The
// receipt is the response the original request returned, so a replay serves
// exactly what the client would have received.
func (h *SettingValuesHandler) recordMutation(
r *http.Request,
store userstore.UserStore,
mutationID, requestHash string,
response settingValueResponse,
) {
receipt, _ := json.Marshal(response)
// Best effort: the write already succeeded, and failing the request now
// would tell the client the opposite of the truth. A missing receipt costs
// at most a duplicate write on retry, which upsert makes harmless.
_, _, _ = store.PutSettingMutation(r.Context(), userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: receipt,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
}
func (h *SettingValuesHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
store, err := h.storeProvider.ForUser(r.Context(), apimw.GetUserID(r.Context()))
if err != nil {
@@ -908,6 +1312,37 @@ func (h *SettingValuesHandler) definitionFor(w http.ResponseWriter, key string)
return def, true
}
// clientFamilyForKeys validates an optional family header for effective reads.
// An absent header deliberately drops the profile_client layer so pre-revision
// 5 callers keep resolving broader fallbacks; explicit profile_client reads and
// writes still require the header in identityForSessionKey. The server never
// guesses this identity from X-Silo-Device-Platform: that header is free-form
// display metadata, while client_family is a closed storage key shared by like
// clients.
func (h *SettingValuesHandler) clientFamilyForKeys(
w http.ResponseWriter, r *http.Request, keys []string,
) (settingscontract.ClientFamily, bool, bool) {
eligible := false
for _, key := range keys {
if def, ok := h.contract.Lookup(key); ok && def.AllowsScope(settingscontract.ScopeProfileClient) {
eligible = true
break
}
}
value := strings.TrimSpace(r.Header.Get(clientFamilyHeader))
if value == "" {
return "", false, true
}
family := settingscontract.ClientFamily(value)
if !family.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
return "", false, false
}
return family, eligible, true
}
// identityFromRequest builds and validates the scope identity a request names.
//
// Scope comes from the query string rather than the path so one route serves
@@ -947,6 +1382,15 @@ func (h *SettingValuesHandler) identityForSessionKey(
identity.ProfileID = named
}
}
if scope == settingscontract.ScopeProfileClient {
family := settingscontract.ClientFamily(strings.TrimSpace(r.Header.Get(clientFamilyHeader)))
if !family.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
return userstore.SettingIdentity{}, false
}
identity.ClientFamily = family
}
if scope == settingscontract.ScopeProfileDevice {
// A device may be named explicitly so one device can manage another's
// settings — the screen that lists your devices edits them in place.
@@ -1064,7 +1508,7 @@ func (h *SettingValuesHandler) keyedScope(
scope := settingscontract.Scope(strings.TrimSpace(query.Get("scope")))
if scope == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"A scope is required: account, profile, profile_device, profile_library or profile_series")
"A scope is required: account, profile, profile_client, profile_device, profile_library or profile_series")
return "", "", false
}
return key, scope, true
@@ -1133,20 +1577,22 @@ func (h *SettingValuesHandler) libraryContextExists(
func sameSettingContext(a, b userstore.SettingIdentity) bool {
return a.Scope == b.Scope && a.ProfileID == b.ProfileID &&
a.DeviceID == b.DeviceID && a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
a.ClientFamily == b.ClientFamily && a.DeviceID == b.DeviceID &&
a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
}
func settingValueToResponse(value userstore.SettingValue) settingValueResponse {
return settingValueResponse{
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
ClientFamily: string(value.ClientFamily),
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
}
}
@@ -1169,14 +1615,16 @@ func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueRes
if eff.Identity != nil {
out.Scope = string(eff.Identity.Scope)
out.ProfileID = eff.Identity.ProfileID
out.ClientFamily = string(eff.Identity.ClientFamily)
out.DeviceID = eff.Identity.DeviceID
out.LibraryID = eff.Identity.LibraryID
out.SeriesID = eff.Identity.SeriesID
out.SourceContext = &effectiveSourceContextResponse{
ProfileID: eff.Identity.ProfileID,
DeviceID: eff.Identity.DeviceID,
LibraryID: eff.Identity.LibraryID,
SeriesID: eff.Identity.SeriesID,
ProfileID: eff.Identity.ProfileID,
ClientFamily: string(eff.Identity.ClientFamily),
DeviceID: eff.Identity.DeviceID,
LibraryID: eff.Identity.LibraryID,
SeriesID: eff.Identity.SeriesID,
}
}
return out
@@ -1307,9 +1755,36 @@ func hashMutationRequest(identity userstore.SettingIdentity, value json.RawMessa
sum.Write([]byte(identity.SeriesID))
sum.Write([]byte{0})
sum.Write(value)
// Preserve the established fingerprint for the five pre-existing scopes so
// an in-flight retry made across this server upgrade still replays. Only the
// new profile_client identity appends a family discriminator.
if identity.ClientFamily != "" {
sum.Write([]byte{0})
sum.Write([]byte(identity.ClientFamily))
}
return hex.EncodeToString(sum.Sum(nil))
}
func hashNavigationShortcutMutation(
identity userstore.SettingIdentity,
item navigationShortcutItem,
present bool,
) string {
// Removing a destination ignores presentation fields, so the fingerprint
// does too. Reusing one mutation id for the same remove with a refreshed
// label is the same operation; adding includes the label because it can
// update that field in place.
if !present {
item.Label = ""
}
canonical, _ := json.Marshal(struct {
Operation string `json:"operation"`
Item navigationShortcutItem `json:"item"`
Present bool `json:"present"`
}{Operation: "set_navigation_shortcut_presence", Item: item, Present: present})
return hashMutationRequest(identity, canonical)
}
func writeRawJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -5,9 +5,12 @@ import (
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const adminNavigationShortcutRepairMessage = "Use whole-document PUT on this admin resource to clear or replace navigation shortcuts"
// Admin projections of the canonical settings API. These replace the string
// registry's /admin/users/{id}/settings* and device-settings* routes with the
// same typed surface clients use on /settings/values: the same validation, the
@@ -81,6 +84,11 @@ func (h *SettingValuesHandler) HandleAdminDeleteUserSettingValue(w http.Response
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
adminNavigationShortcutRepairMessage)
return
}
h.deleteValueAt(w, r, store, userID, identity)
}
@@ -128,6 +136,14 @@ func (h *SettingValuesHandler) adminIdentityFromRequest(
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileClient {
identity.ClientFamily = settingscontract.ClientFamily(strings.TrimSpace(query.Get("client_family")))
if !identity.ClientFamily.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"client_family must be one of tv, mobile, tablet, desktop or web")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = strings.TrimSpace(query.Get("device_id"))
if identity.DeviceID == "" {
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -129,6 +130,10 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
settingscontract.ScopeProfile: {
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
},
settingscontract.ScopeProfileClient: {
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
},
settingscontract.ScopeProfileDevice: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
@@ -145,6 +150,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
values := map[settingscontract.Scope]string{
settingscontract.ScopeAccount: `"de"`,
settingscontract.ScopeProfile: `"always"`,
settingscontract.ScopeProfileClient: `{"items":[{"type":"builtin","destination":"home"}]}`,
settingscontract.ScopeProfileDevice: `"en"`,
settingscontract.ScopeProfileLibrary: `"fr"`,
settingscontract.ScopeProfileSeries: `"ja"`,
@@ -186,6 +192,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
continue
}
if got.Key != want.Key || got.ProfileID != want.ProfileID ||
got.ClientFamily != string(want.ClientFamily) ||
got.DeviceID != want.DeviceID || got.LibraryID != want.LibraryID ||
got.SeriesID != want.SeriesID {
t.Errorf("listed identity at %s = %+v, want %+v", got.Scope, got, want)
@@ -243,6 +250,83 @@ func TestAdminSetAndDeleteAtExplicitScopeRoundTrip(t *testing.T) {
}
}
func TestAdminNavigationShortcutRepairPreservesRevisionHistory(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
identity := userstore.SettingIdentity{
Key: "nav.shortcuts", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
seeded, err := env.targetStore.UpsertSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":42,"label":"Movies"}]}`))
if err != nil {
t.Fatalf("seed shortcuts: %v", err)
}
if seeded.Revision != 1 {
t.Fatalf("seed revision = %d, want 1", seeded.Revision)
}
target := "/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusBadRequest {
t.Fatalf("admin repair DELETE = %d, want 400: %s", rec.Code, rec.Body.String())
} else if !bytes.Contains(rec.Body.Bytes(), []byte(`"error":"atomic_update_required"`)) {
t.Fatalf("admin repair DELETE body = %s, want atomic_update_required", rec.Body.String())
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil || got.Revision != 1 {
t.Fatalf("shortcut after rejected DELETE = %+v err=%v, want revision 1 intact", got, err)
}
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":{"items":[]}}`))
if rec.Code != http.StatusOK {
t.Fatalf("admin repair PUT empty = %d: %s", rec.Code, rec.Body.String())
}
var repaired settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &repaired); err != nil {
t.Fatalf("decode repair response: %v", err)
}
if repaired.Revision != 2 || string(repaired.Value) != `{"items":[]}` {
t.Fatalf("repair response = revision %d value %s, want empty revision 2",
repaired.Revision, repaired.Value)
}
cas, ok := env.targetStore.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Fatal("target store does not support compare-and-set")
}
_, err = cas.CompareAndSetSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":99,"label":"Shows"}]}`),
seeded.Revision)
if !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale CAS after admin repair = %v, want revision conflict", err)
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil ||
got.Revision != 2 || string(got.Value) != `{"items":[]}` {
t.Fatalf("shortcut after stale CAS = %+v err=%v, want empty revision 2", got, err)
}
}
func TestAdminProfileClientUsesExplicitQueryIdentity(t *testing.T) {
env := newAdminValuesEnv(t)
target := "/admin/users/7/settings/values/nav.primary_menu" +
"?scope=profile_client&profile_id=profile-1&client_family=tv"
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"}]}}`)
rec := env.do(t, "admin", http.MethodPut, target, body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding: %v", err)
}
if stored.Scope != "profile_client" || stored.ProfileID != "profile-1" || stored.ClientFamily != "tv" {
t.Fatalf("stored identity = %+v, want profile-1/tv", stored)
}
missing := "/admin/users/7/settings/values/nav.primary_menu?scope=profile_client&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodPut, missing, body); rec.Code != http.StatusBadRequest {
t.Fatalf("PUT without family = %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// TestAdminSetRejectsInvalidValueLikeTheSessionRoute pins that the admin write
// is the same validation path as /settings/values, not a second validator: an
// invalid value fails with the identical status, code and message.
+691 -5
View File
@@ -3,14 +3,19 @@ package handlers
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/go-chi/chi/v5"
@@ -20,6 +25,7 @@ import (
"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/notifications"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
@@ -36,6 +42,7 @@ func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserSt
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
@@ -65,6 +72,7 @@ func valuesRequest(method, target string, body []byte) *http.Request {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
req.Header.Set(deviceIDHeader, "device-1")
req.Header.Set(clientFamilyHeader, "web")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, "profile-1"))
}
@@ -94,6 +102,499 @@ func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query strin
return rec
}
func routeNavigationShortcutMutation(
t *testing.T,
h *SettingValuesHandler,
body string,
mutationID string,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(http.MethodPut, "/settings/values/nav.shortcuts/item", []byte(body))
if mutationID != "" {
req.Header.Set(mutationIDHeader, mutationID)
}
rec := httptest.NewRecorder()
h.HandleSetNavigationShortcut(rec, req)
return rec
}
func decodeShortcutResponse(t *testing.T, rec *httptest.ResponseRecorder) (settingValueResponse, navigationShortcutDocument) {
t.Helper()
var response settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode shortcut response: %v; body=%s", err, rec.Body.String())
}
var document navigationShortcutDocument
if err := json.Unmarshal(response.Value, &document); err != nil {
t.Fatalf("decode shortcut document: %v; value=%s", err, response.Value)
}
return response, document
}
func TestNavigationShortcutMutationLifecycle(t *testing.T) {
handler, _ := newValuesTestHandler(t)
addLibrary := `{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`
rec := routeNavigationShortcutMutation(t, handler, addLibrary, "")
if rec.Code != http.StatusOK {
t.Fatalf("add library = %d: %s", rec.Code, rec.Body.String())
}
response, document := decodeShortcutResponse(t, rec)
if response.Key != settingskeys.NavShortcuts || response.Scope != "profile" || response.Revision != 1 {
t.Fatalf("add response = %+v, want profile nav.shortcuts revision 1", response)
}
if len(document.Items) != 1 || document.Items[0].Label != "Movies" {
t.Fatalf("add document = %+v, want Movies", document)
}
addSection := `{"item":{"type":"section","library_id":42,"section_id":"recent","label":"Recent"},"present":true}`
rec = routeNavigationShortcutMutation(t, handler, addSection, "")
if rec.Code != http.StatusOK {
t.Fatalf("add section = %d: %s", rec.Code, rec.Body.String())
}
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 2 || len(document.Items) != 2 || document.Items[1].SectionID != "recent" {
t.Fatalf("add section = revision %d document %+v, want both items at revision 2", response.Revision, document)
}
refreshLibrary := `{"item":{"type":"library","library_id":42,"label":"Films"},"present":true}`
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 2 || document.Items[0].Label != "Films" || document.Items[1].SectionID != "recent" {
t.Fatalf("label refresh reordered or dropped items: revision %d document %+v", response.Revision, document)
}
// Exact desired state is a no-op: no revision churn and no duplicate.
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 2 {
t.Fatalf("repeat add = revision %d document %+v, want unchanged revision 3", response.Revision, document)
}
// Removal matches semantic identity only; a stale label cannot stop it.
removeLibrary := `{"item":{"type":"library","library_id":42,"label":"Old label"},"present":false}`
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
response, document = decodeShortcutResponse(t, rec)
if response.Revision != 4 || len(document.Items) != 1 || document.Items[0].SectionID != "recent" {
t.Fatalf("remove library = revision %d document %+v, want section preserved", response.Revision, document)
}
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
response, _ = decodeShortcutResponse(t, rec)
if response.Revision != 4 {
t.Fatalf("repeat remove revision = %d, want unchanged 4", response.Revision)
}
}
func TestNavigationShortcutCollectionIdentityIncludesOptionalLibrary(t *testing.T) {
handler, _ := newValuesTestHandler(t)
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
for _, body := range []string{global, withinLibrary} {
if rec := routeNavigationShortcutMutation(t, handler, body, ""); rec.Code != http.StatusOK {
t.Fatalf("add collection = %d: %s", rec.Code, rec.Body.String())
}
}
removeGlobal := `{"item":{"type":"collection","collection_id":"favorites","label":"Ignored"},"present":false}`
rec := routeNavigationShortcutMutation(t, handler, removeGlobal, "")
response, document := decodeShortcutResponse(t, rec)
if response.Revision != 3 || len(document.Items) != 1 || document.Items[0].LibraryID == nil || *document.Items[0].LibraryID != 42 {
t.Fatalf("global removal = revision %d document %+v, want scoped collection preserved", response.Revision, document)
}
}
func TestNavigationShortcutCollectionLibraryIDValidationCannotMutateExistingTargets(t *testing.T) {
handler, store := newValuesTestHandler(t)
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
for _, tc := range []struct {
name string
body string
}{
{"omitted library id is global", global},
{"positive library id", withinLibrary},
} {
t.Run(tc.name, func(t *testing.T) {
rec := routeNavigationShortcutMutation(t, handler, tc.body, "")
if rec.Code != http.StatusOK {
t.Fatalf("valid collection = %d: %s", rec.Code, rec.Body.String())
}
})
}
invalidLibraryIDs := map[string]string{
"explicit null": `null`,
"string": `"42"`,
"zero": `0`,
}
for name, libraryID := range invalidLibraryIDs {
t.Run(name, func(t *testing.T) {
body := fmt.Sprintf(
`{"item":{"type":"collection","library_id":%s,"collection_id":"favorites","label":"Ignored"},"present":false}`,
libraryID,
)
rec := routeNavigationShortcutMutation(t, handler, body, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
t.Fatalf("invalid collection library_id = %d: %s", rec.Code, rec.Body.String())
}
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || stored == nil {
t.Fatalf("read catalog after rejected remove: value=%+v err=%v", stored, err)
}
var document navigationShortcutDocument
if err := json.Unmarshal(stored.Value, &document); err != nil {
t.Fatalf("decode catalog after rejected remove: %v", err)
}
if stored.Revision != 2 || len(document.Items) != 2 {
t.Fatalf("rejected remove mutated catalog: revision %d items %+v", stored.Revision, document.Items)
}
if document.Items[0].LibraryID != nil {
t.Fatalf("global collection gained library identity: %+v", document.Items[0])
}
if document.Items[1].LibraryID == nil || *document.Items[1].LibraryID != 42 {
t.Fatalf("library-specific collection changed identity: %+v", document.Items[1])
}
})
}
}
func TestNavigationShortcutMutationIdempotency(t *testing.T) {
handler, _ := newValuesTestHandler(t)
const mutationID = "d615e91d-988f-4928-bb32-8956a26c7608"
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
if first.Code != http.StatusOK {
t.Fatalf("first mutation = %d: %s", first.Code, first.Body.String())
}
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
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())
}
if strings.TrimSpace(replay.Body.String()) != strings.TrimSpace(first.Body.String()) {
t.Fatalf("replay body = %s, want exact %s", replay.Body.String(), first.Body.String())
}
conflict := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"Renamed"},"present":true}`,
mutationID)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "mutation_id_conflict") {
t.Fatalf("mutation id reuse = %d: %s", conflict.Code, conflict.Body.String())
}
// A remove's label is not part of its semantics or request fingerprint.
const removeID = "f2f65f5e-918a-46bf-bee0-65a84491d298"
removed := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"First"},"present":false}`,
removeID)
removeReplay := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"collection","collection_id":"watchlist","label":"Second"},"present":false}`,
removeID)
if removed.Code != http.StatusOK || removeReplay.Code != http.StatusOK || removeReplay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("semantic remove replay = %d/%d header %q", removed.Code, removeReplay.Code, removeReplay.Header().Get("X-Silo-Idempotent-Replay"))
}
}
func TestNavigationShortcutMutationThroughNotificationWrappedProvider(t *testing.T) {
baseHandler, store := newValuesTestHandler(t)
provider := notifications.WrapUserStoreProvider(
testUserStoreProvider{store: store},
&notifications.System{},
)
handler := NewSettingValuesHandler(provider, baseHandler.contract)
const mutationID = "wrapped-provider-mutation"
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
if first.Code != http.StatusOK {
t.Fatalf("wrapped first mutation = %d: %s", first.Code, first.Body.String())
}
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Fatalf("wrapped replay = %d header %q: %s",
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
}
if !bytes.Equal(bytes.TrimSpace(first.Body.Bytes()), bytes.TrimSpace(replay.Body.Bytes())) {
t.Fatalf("wrapped replay body = %s, want %s", replay.Body.String(), first.Body.String())
}
}
func TestNavigationShortcutConcurrentSameMutationID(t *testing.T) {
for _, tc := range []struct {
name string
bodies []string
wantConflicts int
}{
{
name: "same hash replays one receipt",
bodies: []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
},
},
{
name: "different hash conflicts before CAS",
bodies: []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
},
wantConflicts: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
handler, store := newValuesTestHandler(t)
const mutationID = "concurrent-same-id"
start := make(chan struct{})
responses := make(chan *httptest.ResponseRecorder, len(tc.bodies))
var ready sync.WaitGroup
ready.Add(len(tc.bodies))
for _, body := range tc.bodies {
body := body
go func() {
ready.Done()
<-start
responses <- routeNavigationShortcutMutation(t, handler, body, mutationID)
}()
}
ready.Wait()
close(start)
conflicts := 0
replays := 0
var successes [][]byte
for range tc.bodies {
rec := <-responses
switch rec.Code {
case http.StatusOK:
successes = append(successes, bytes.TrimSpace(rec.Body.Bytes()))
if rec.Header().Get("X-Silo-Idempotent-Replay") == "true" {
replays++
}
case http.StatusConflict:
if !strings.Contains(rec.Body.String(), "mutation_id_conflict") {
t.Fatalf("conflict = %s, want mutation_id_conflict", rec.Body.String())
}
conflicts++
default:
t.Fatalf("concurrent mutation = %d: %s", rec.Code, rec.Body.String())
}
}
if conflicts != tc.wantConflicts {
t.Fatalf("conflicts = %d, want %d", conflicts, tc.wantConflicts)
}
if tc.wantConflicts == 0 {
if len(successes) != 2 || replays != 1 || !bytes.Equal(successes[0], successes[1]) {
t.Fatalf("same-hash outcomes: successes=%d replays=%d bodies=%q", len(successes), replays, successes)
}
} else if len(successes) != 1 || replays != 0 {
t.Fatalf("different-hash outcomes: successes=%d replays=%d", len(successes), replays)
}
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || stored == nil || stored.Revision != 1 {
t.Fatalf("stored shortcut = %+v (%v), want exactly one CAS revision", stored, err)
}
receipt, err := store.GetSettingMutation(context.Background(), mutationID)
if err != nil || receipt == nil {
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
}
if len(successes) > 0 && !bytes.Equal(successes[0], bytes.TrimSpace(receipt.Result)) {
t.Fatalf("success = %s, receipt = %s", successes[0], receipt.Result)
}
})
}
}
func TestNavigationShortcutMutationValidationAndWholeDocumentGuard(t *testing.T) {
handler, store := newValuesTestHandler(t)
wholeDocument := routeValues(t, handler, http.MethodPut, settingskeys.NavShortcuts,
"scope=profile", []byte(`{"value":{"items":[]}}`))
if wholeDocument.Code != http.StatusBadRequest || !strings.Contains(wholeDocument.Body.String(), "atomic_update_required") {
t.Fatalf("whole-document PUT = %d: %s", wholeDocument.Code, wholeDocument.Body.String())
}
if value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}); err != nil || value != nil {
t.Fatalf("guarded PUT stored %+v, err=%v", value, err)
}
invalidBodies := []string{
`{"item":{"type":"builtin","destination":"home","label":"Home"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies","extra":true},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true,"extra":true}`,
`{"item":{"type":"section","library_id":42,"section_id":"","label":"Recent"},"present":true}`,
`{"item":{"type":"collection","collection_id":"favorites","label":" "},"present":true}`,
`{"item":{"type":"library","library_id":0,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":42,"label":"Movies"}}`,
}
for _, body := range invalidBodies {
rec := routeNavigationShortcutMutation(t, handler, body, "")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid body %s = %d: %s", body, rec.Code, rec.Body.String())
}
}
}
func TestNavigationShortcutWholeDocumentDeleteCannotCreateRevisionABA(t *testing.T) {
handler, store := newValuesTestHandler(t)
first := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`, "")
firstResponse, _ := decodeShortcutResponse(t, first)
if first.Code != http.StatusOK || firstResponse.Revision != 1 {
t.Fatalf("seed shortcut = %d revision %d: %s", first.Code, firstResponse.Revision, first.Body.String())
}
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
beforeDelete, err := store.GetSettingValue(context.Background(), identity)
if err != nil || beforeDelete == nil {
t.Fatalf("read shortcut before guarded delete: value=%+v err=%v", beforeDelete, err)
}
deleteRec := routeValues(t, handler, http.MethodDelete, settingskeys.NavShortcuts,
"scope=profile", nil)
if deleteRec.Code != http.StatusBadRequest || !strings.Contains(deleteRec.Body.String(), "atomic_update_required") {
t.Fatalf("whole-document DELETE = %d: %s", deleteRec.Code, deleteRec.Body.String())
}
second := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`, "")
secondResponse, secondDocument := decodeShortcutResponse(t, second)
if second.Code != http.StatusOK || secondResponse.Revision != 2 || len(secondDocument.Items) != 2 {
t.Fatalf("second shortcut = %d revision %d document %+v", second.Code, secondResponse.Revision, secondDocument)
}
// If session DELETE had removed and recreated the row, its revision would
// return to one and this stale writer could pass an ABA-shaped precondition.
// Keeping the row alive makes the stale revision conflict instead.
cas := store.(userstore.SettingValueCompareAndSetter)
if _, err := cas.CompareAndSetSettingValue(context.Background(), identity,
beforeDelete.Value, beforeDelete.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale CAS after guarded DELETE error = %v, want revision conflict", err)
}
final, err := store.GetSettingValue(context.Background(), identity)
if err != nil || final == nil || final.Revision != 2 {
t.Fatalf("final shortcut row = %+v err=%v, want revision 2", final, err)
}
}
func TestNavigationShortcutMutationEnforcesDocumentCap(t *testing.T) {
handler, store := newValuesTestHandler(t)
items := make([]navigationShortcutItem, 0, 256)
for id := 1; id <= 256; id++ {
libraryID := id
items = append(items, navigationShortcutItem{Type: "library", LibraryID: &libraryID, Label: fmt.Sprintf("Library %d", id)})
}
raw, err := json.Marshal(navigationShortcutDocument{Items: items})
if err != nil {
t.Fatal(err)
}
def, _ := handler.contract.Lookup(settingskeys.NavShortcuts)
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
t.Fatalf("normalize seed: %v", err)
}
identity := userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
seed, err := store.UpsertSettingValue(context.Background(), identity, normalized)
if err != nil {
t.Fatalf("seed full catalog: %v", err)
}
rec := routeNavigationShortcutMutation(t, handler,
`{"item":{"type":"library","library_id":257,"label":"Too Many"},"present":true}`, "")
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
t.Fatalf("257th shortcut = %d: %s", rec.Code, rec.Body.String())
}
got, err := store.GetSettingValue(context.Background(), identity)
if err != nil || got == nil || got.Revision != seed.Revision {
t.Fatalf("catalog after rejected add = %+v, err=%v; want unchanged revision %d", got, err, seed.Revision)
}
}
type synchronizedShortcutStore struct {
userstore.UserStore
cas userstore.SettingValueCompareAndSetter
reads atomic.Int32
ready chan struct{}
once sync.Once
casMu sync.Mutex
}
func (s *synchronizedShortcutStore) GetSettingValue(ctx context.Context, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
value, err := s.UserStore.GetSettingValue(ctx, id)
if err == nil && id.Key == settingskeys.NavShortcuts {
read := s.reads.Add(1)
if read <= 2 {
if read == 2 {
s.once.Do(func() { close(s.ready) })
}
<-s.ready
}
}
return value, err
}
func (s *synchronizedShortcutStore) CompareAndSetSettingValue(
ctx context.Context,
id userstore.SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*userstore.SettingValue, error) {
s.casMu.Lock()
defer s.casMu.Unlock()
return s.cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
}
func TestNavigationShortcutConcurrentAddsMerge(t *testing.T) {
baseHandler, baseStore := newValuesTestHandler(t)
wrapped := &synchronizedShortcutStore{
UserStore: baseStore,
cas: baseStore.(userstore.SettingValueCompareAndSetter),
ready: make(chan struct{}),
}
handler := NewSettingValuesHandler(testUserStoreProvider{store: wrapped}, baseHandler.contract)
bodies := []string{
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
}
responses := make(chan *httptest.ResponseRecorder, len(bodies))
for _, body := range bodies {
body := body
go func() {
responses <- routeNavigationShortcutMutation(t, handler, body, "")
}()
}
for range bodies {
rec := <-responses
if rec.Code != http.StatusOK {
t.Fatalf("concurrent add = %d: %s", rec.Code, rec.Body.String())
}
}
got, err := baseStore.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
})
if err != nil || got == nil {
t.Fatalf("read merged catalog: value=%+v err=%v", got, err)
}
var document navigationShortcutDocument
if err := json.Unmarshal(got.Value, &document); err != nil {
t.Fatalf("decode merged catalog: %v", err)
}
if got.Revision != 2 || len(document.Items) != 2 {
t.Fatalf("merged catalog = revision %d items %+v, want both adds at revision 2", got.Revision, document.Items)
}
}
func TestSettingValuesRoundTrip(t *testing.T) {
handler, _ := newValuesTestHandler(t)
@@ -131,6 +632,161 @@ func TestSettingValuesRoundTrip(t *testing.T) {
}
}
func TestProfileClientValueUsesExplicitFamilyHeader(t *testing.T) {
handler, _ := newValuesTestHandler(t)
key := settingskeys.NavPrimaryMenu
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"},` +
`{"type":"library","library_id":42,"label":"Movies"}]}}`)
rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client", body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT profile_client = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding response: %v", err)
}
if stored.Scope != "profile_client" || stored.ClientFamily != "web" {
t.Fatalf("stored scope/family = %q/%q, want profile_client/web", stored.Scope, stored.ClientFamily)
}
var menu navigationShortcutDocument
if err := json.Unmarshal(stored.Value, &menu); err != nil {
t.Fatalf("decoding stored primary menu: %v", err)
}
if len(menu.Items) != 2 || menu.Items[1].Label != "Movies" {
t.Fatalf("stored non-builtin menu item = %+v, want labeled Movies library", menu.Items)
}
for _, tc := range []struct {
name string
header string
}{
{"missing", ""},
{"not canonical", "TV"},
{"unknown", "car"},
} {
t.Run(tc.name, func(t *testing.T) {
req := valuesRequest(http.MethodPut,
"/settings/values/"+key+"?scope=profile_client", body)
if tc.header == "" {
req.Header.Del(clientFamilyHeader)
} else {
req.Header.Set(clientFamilyHeader, tc.header)
}
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", key)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("PUT with family %q = %d, want 400: %s", tc.header, rec.Code, rec.Body.String())
}
})
}
}
func TestMutationHashPreservesExistingScopesAndSeparatesClientFamilies(t *testing.T) {
identity := userstore.SettingIdentity{
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "device-1",
}
value := json.RawMessage(`"always"`)
legacyPayload := append([]byte(
"playback.subtitle_mode\x00profile_device\x00profile-1\x00device-1\x000\x00\x00",
), value...)
want := sha256.Sum256(legacyPayload)
if got := hashMutationRequest(identity, value); got != hex.EncodeToString(want[:]) {
t.Fatalf("existing-scope mutation hash changed across the profile_client rollout: %s", got)
}
client := userstore.SettingIdentity{
Key: settingskeys.NavPrimaryMenu, Scope: settingscontract.ScopeProfileClient,
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
}
tv := hashMutationRequest(client, json.RawMessage(`null`))
client.ClientFamily = settingscontract.ClientFamilyMobile
if mobile := hashMutationRequest(client, json.RawMessage(`null`)); mobile == tv {
t.Fatal("profile_client mutation hashes do not distinguish client families")
}
}
func TestEffectiveClientFamilyIsOptionalButValidated(t *testing.T) {
handler, _ := newValuesTestHandler(t)
key := settingskeys.UiCardPresentation
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile",
[]byte(`{"value":{"poster_size":"compact","caption":"title"}}`)); rec.Code != http.StatusOK {
t.Fatalf("seed profile fallback = %d: %s", rec.Code, rec.Body.String())
}
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client",
[]byte(`{"value":{"poster_size":"large","caption":"artwork"}}`)); rec.Code != http.StatusOK {
t.Fatalf("seed family value = %d: %s", rec.Code, rec.Body.String())
}
effective := func(t *testing.T, family *string) (int, effectiveSettingValueResponse, string) {
t.Helper()
req := valuesRequest(http.MethodGet, "/settings/values/effective?keys="+key, nil)
if family == nil {
req.Header.Del(clientFamilyHeader)
} else {
req.Header.Set(clientFamilyHeader, *family)
}
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
return rec.Code, effectiveSettingValueResponse{}, rec.Body.String()
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode effective response: %v", err)
}
if len(body.Settings) != 1 {
t.Fatalf("settings = %d, want 1", len(body.Settings))
}
return rec.Code, body.Settings[0], rec.Body.String()
}
t.Run("absent skips profile client layer", func(t *testing.T) {
code, got, body := effective(t, nil)
if code != http.StatusOK {
t.Fatalf("effective without client family = %d: %s", code, body)
}
if got.Source != string(settingscontract.ScopeProfile) || string(got.Value) != `{"poster_size":"compact","caption":"title"}` {
t.Fatalf("effective without family = %s from %s, want profile fallback", got.Value, got.Source)
}
})
t.Run("absent remains compatible with resolve all", func(t *testing.T) {
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
req.Header.Del(clientFamilyHeader)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("resolve all without client family = %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("valid selects profile client layer", func(t *testing.T) {
family := "web"
code, got, body := effective(t, &family)
if code != http.StatusOK {
t.Fatalf("effective with client family = %d: %s", code, body)
}
if got.Source != string(settingscontract.ScopeProfileClient) || got.ClientFamily != family {
t.Fatalf("effective with family = %s/%s, want profile_client/web", got.Source, got.ClientFamily)
}
})
t.Run("nonempty invalid is rejected", func(t *testing.T) {
family := "TV"
code, _, _ := effective(t, &family)
if code != http.StatusBadRequest {
t.Fatalf("effective with invalid client family = %d, want 400", code)
}
})
}
func TestGetSettingValuesReportsSetAndUnsetAtOneScope(t *testing.T) {
handler, store := newValuesTestHandler(t)
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
@@ -697,12 +1353,34 @@ type failingUpsertStore struct {
userstore.UserStore
}
type failingUpsertMutationWriter struct {
userstore.SettingMutationWriter
}
func (failingUpsertStore) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("simulated write failure")
}
func (s failingUpsertStore) WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(userstore.SettingMutationWriter) error,
) error {
transactioner := s.UserStore.(userstore.SettingMutationTransactioner)
return transactioner.WithSettingMutationTransaction(ctx, mutationID,
func(writer userstore.SettingMutationWriter) error {
return fn(failingUpsertMutationWriter{SettingMutationWriter: writer})
})
}
func (failingUpsertMutationWriter) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("simulated write failure")
}
// TestFailedWritesLeaveNoReceipt: a receipt for a write that never landed
// would turn the client's retry of a 500 into a silent success replay.
func TestFailedWritesLeaveNoReceipt(t *testing.T) {
@@ -815,9 +1493,10 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
}
var body struct {
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
ClientFamilies []string `json:"client_families"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
@@ -827,8 +1506,15 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
t.Errorf("reported %d/%d, want %d/%d",
body.APIVersion, body.Revision, contract.APIVersion, contract.Revision)
}
if len(body.Scopes) != 5 {
t.Errorf("reported %d scopes, want 5", len(body.Scopes))
wantScopes := []string{
"account", "profile", "profile_client", "profile_device", "profile_library", "profile_series",
}
if !slices.Equal(body.Scopes, wantScopes) {
t.Errorf("reported scopes %v, want %v", body.Scopes, wantScopes)
}
wantFamilies := []string{"tv", "mobile", "tablet", "desktop", "web"}
if !slices.Equal(body.ClientFamilies, wantFamilies) {
t.Errorf("reported client families %v, want %v", body.ClientFamilies, wantFamilies)
}
}
+152 -31
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,9 +236,58 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
return
}
// 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(playback.DownloadedSubtitleIDParamV3)); rawID != "" {
downloadedID, parseErr := strconv.Atoi(rawID)
if parseErr != nil || downloadedID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
return
}
if h.SubtitleRepo == nil || h.S3Client == nil {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
return
}
downloaded, lookupErr := h.SubtitleRepo.GetDownloadedSubtitle(r.Context(), downloadedID)
if lookupErr != nil {
slog.ErrorContext(r.Context(), "get downloaded subtitle failed", "component", "api",
"file_id", file.ID,
"downloaded_subtitle_id", downloadedID,
"error", lookupErr,
)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load downloaded subtitle")
return
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
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" {
@@ -231,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
}
@@ -258,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
@@ -287,32 +362,11 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
dl := downloaded[downloadedIndex]
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key)
if err != nil {
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
if r.Method == http.MethodHead {
writeSubtitleRepresentationHead(w, requestedFormat)
return
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
return
}
// If the subtitle is already VTT, serve directly.
if dl.Format == subtitles.FormatVTT {
playback.ServeSubtitle(w, data, "vtt")
return
}
// Convert to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
}
playback.ServeSubtitle(w, vttData, "vtt")
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
return
}
}
@@ -320,6 +374,73 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
}
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")
return
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, subtitleFormatASS)
return
}
// If the subtitle is already VTT, serve directly.
if subtitle.Format == subtitles.FormatVTT {
playback.ServeSubtitle(w, data, "vtt")
return
}
// Convert other text formats to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(subtitle.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
}
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
@@ -493,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
@@ -515,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",
@@ -560,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")
+282
View File
@@ -7,13 +7,17 @@ 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"
)
type hookedSessionManager struct {
@@ -197,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
@@ -246,6 +321,53 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) {
}
}
func TestHandleSubtitleUsesBoundDownloadedIdentityAfterInventoryReorder(t *testing.T) {
file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: "/tmp/movie.mkv", Duration: 3600}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"}
// The mutable ordinal now points at a different subtitle. An ID-bound URL
// must still fetch 71 without consulting this reordered list.
repo.list = []subtitles.DownloadedSubtitle{
{ID: 72, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "other-72.vtt"},
{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"},
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
handler.SubtitleRepo = repo
handler.S3Client = subtitleContentS3Client{objects: map[string][]byte{
"selected-71.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nselected-71\n"),
"other-72.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nother-72\n"),
}}
handler.S3Bucket = "test-bucket"
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71", 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 || !strings.Contains(rr.Body.String(), "selected-71") || strings.Contains(rr.Body.String(), "other-72") {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
}
type subtitleContentS3Client struct {
objects map[string][]byte
}
func (subtitleContentS3Client) PutObject(context.Context, string, string, []byte) error { return nil }
func (c subtitleContentS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
return append([]byte(nil), c.objects[key]...), nil
}
func (subtitleContentS3Client) DeleteObject(context.Context, string, string) error { return nil }
func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
@@ -314,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
}
+16 -2
View File
@@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
byKey map[string]*subtitles.DownloadedSubtitle
// getErr, when set, is returned by GetDownloadedSubtitle to simulate a
// backing-store failure.
getErr error
// listErr, when set, is returned by ListDownloadedSubtitles to simulate a
// backing-store failure.
listErr error
listErr error
list []subtitles.DownloadedSubtitle
listResults [][]subtitles.DownloadedSubtitle
listCalls int
}
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
@@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
if m.getErr != nil {
return nil, m.getErr
}
if sub, ok := m.subtitles[id]; ok {
copy := *sub
return &copy, nil
@@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in
}
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
m.listCalls++
if m.listErr != nil {
return nil, m.listErr
}
return nil, nil
if len(m.listResults) > 0 {
index := min(m.listCalls-1, len(m.listResults)-1)
return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil
}
return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil
}
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
+73 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/http"
"sync"
"github.com/Silo-Server/silo-server/internal/buildinfo"
"github.com/Silo-Server/silo-server/internal/nodepool"
@@ -28,24 +29,85 @@ func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, f
}
}
// NodeHWAccel reports one transcode node's GPU inventory.
type NodeHWAccel struct {
NodeURL string `json:"node_url"`
NodeName string `json:"node_name,omitempty"`
Resolved string `json:"resolved,omitempty"`
RenderDevices []string `json:"render_devices,omitempty"`
RenderDeviceDetails []playback.RenderDeviceInfo `json:"render_device_details,omitempty"`
Error string `json:"error,omitempty"`
}
// HWAccelInventory is the admin hw-accel response: the primary probe (flat,
// backward compatible with the previous single-probe response) plus every
// healthy node's inventory. playback.hw_device is one cluster-wide value, so
// the UI needs all node inventories to warn when they diverge (devices must
// exist at identical paths on every node for the setting to be safe).
type HWAccelInventory struct {
playback.HWAccelInfo
Nodes []NodeHWAccel `json:"nodes,omitempty"`
}
// HandleHWAccel handles GET /admin/system/hw-accel.
// When transcode nodes are registered it delegates to the first healthy node.
// Otherwise it probes the local host.
// With transcode nodes registered it probes every healthy node (the primary
// flat fields come from the first that responds, preserving the historical
// shape); with none it probes the local host.
func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
var healthy []*nodepool.Node
if h.transcodePool != nil {
if node := h.transcodePool.Acquire(); node != nil {
info, err := h.fetchRemoteHWAccel(r.Context(), node)
if err == nil {
writeJSON(w, http.StatusOK, info)
return
for _, node := range h.transcodePool.Nodes() {
if node != nil && node.Enabled && node.Healthy {
healthy = append(healthy, node)
}
slog.WarnContext(r.Context(), "hw-accel: remote node probe failed, falling back to local", "component", "api",
"node", node.URL, "error", err)
}
}
if len(healthy) == 0 {
writeJSON(w, http.StatusOK, HWAccelInventory{HWAccelInfo: playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)})
return
}
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
writeJSON(w, http.StatusOK, info)
inventory := HWAccelInventory{Nodes: make([]NodeHWAccel, len(healthy))}
infos := make([]playback.HWAccelInfo, len(healthy))
errs := make([]error, len(healthy))
var wg sync.WaitGroup
for i, node := range healthy {
wg.Add(1)
go func() {
defer wg.Done()
infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
}()
}
wg.Wait()
for i, node := range healthy {
entry := NodeHWAccel{NodeURL: node.URL, NodeName: node.Name}
if errs[i] != nil {
slog.WarnContext(r.Context(), "hw-accel: node probe failed", "component", "api",
"node", node.URL, "error", errs[i])
entry.Error = errs[i].Error()
} else {
entry.Resolved = infos[i].Resolved
entry.RenderDevices = infos[i].RenderDevices
entry.RenderDeviceDetails = infos[i].RenderDeviceDetails
}
inventory.Nodes[i] = entry
}
// Primary flat fields: the first node that answered; if none did, fall
// back to a local probe (matching the historical fallback behavior).
primaried := false
for i := range healthy {
if errs[i] == nil {
inventory.HWAccelInfo = infos[i]
primaried = true
break
}
}
if !primaried {
inventory.HWAccelInfo = playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
}
writeJSON(w, http.StatusOK, inventory)
}
// HandleBuildInfo handles GET /admin/system/build.
+110
View File
@@ -9,6 +9,8 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/buildinfo"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestSystemBuildInfoResponse(t *testing.T) {
@@ -86,3 +88,111 @@ func TestSystemBuildInfoUnavailableResponseShape(t *testing.T) {
}
}
}
func TestHandleHWAccelAggregatesAllHealthyNodes(t *testing.T) {
t.Parallel()
nodeA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hw-capabilities" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(playback.HWAccelInfo{
Resolved: "qsv",
RenderDevices: []string{"/dev/dri/renderD128", "/dev/dri/renderD129"},
RenderDeviceDetails: []playback.RenderDeviceInfo{
{Path: "/dev/dri/renderD128", Description: "Intel GPU"},
{Path: "/dev/dri/renderD129", Description: "Intel GPU"},
},
})
}))
defer nodeA.Close()
nodeB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(playback.HWAccelInfo{
Resolved: "vaapi",
RenderDevices: []string{"/dev/dri/renderD128"},
RenderDeviceDetails: []playback.RenderDeviceInfo{
{Path: "/dev/dri/renderD128", Description: "AMD GPU"},
},
})
}))
defer nodeB.Close()
nodeDown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer nodeDown.Close()
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{
{ID: 1, Name: "node-a", URL: nodeA.URL, Enabled: true, Healthy: true},
{ID: 2, Name: "node-b", URL: nodeB.URL, Enabled: true, Healthy: true},
{ID: 3, Name: "node-down", URL: nodeDown.URL, Enabled: true, Healthy: true},
{ID: 4, Name: "node-unhealthy", URL: "http://unreachable.invalid", Enabled: true, Healthy: false},
})
handler := &SystemHandler{transcodePool: pool, jwtSecret: "secret"}
req := httptest.NewRequest(http.MethodGet, "/admin/system/hw-accel", nil)
rec := httptest.NewRecorder()
handler.HandleHWAccel(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var got HWAccelInventory
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
// Flat primary fields keep the historical single-probe shape, sourced
// from the first healthy node that answered.
if got.Resolved != "qsv" || got.Source != "transcode_node" || got.NodeURL != nodeA.URL {
t.Fatalf("primary = resolved %q source %q node %q, want first node's probe", got.Resolved, got.Source, got.NodeURL)
}
// Every healthy node appears, in pool order; the unhealthy one does not.
if len(got.Nodes) != 3 {
t.Fatalf("nodes len = %d, want 3: %+v", len(got.Nodes), got.Nodes)
}
if got.Nodes[0].NodeName != "node-a" || got.Nodes[0].Resolved != "qsv" || len(got.Nodes[0].RenderDeviceDetails) != 2 {
t.Fatalf("node-a entry = %+v", got.Nodes[0])
}
if got.Nodes[1].NodeName != "node-b" || got.Nodes[1].Resolved != "vaapi" || len(got.Nodes[1].RenderDevices) != 1 {
t.Fatalf("node-b entry = %+v", got.Nodes[1])
}
if got.Nodes[2].NodeName != "node-down" || got.Nodes[2].Error == "" {
t.Fatalf("failed node entry = %+v, want populated error", got.Nodes[2])
}
}
func TestHandleHWAccelAllNodeProbesFailFallsBackLocal(t *testing.T) {
t.Parallel()
nodeDown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer nodeDown.Close()
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{
{ID: 1, Name: "node-down", URL: nodeDown.URL, Enabled: true, Healthy: true},
})
handler := &SystemHandler{transcodePool: pool, jwtSecret: "secret"}
req := httptest.NewRequest(http.MethodGet, "/admin/system/hw-accel", nil)
rec := httptest.NewRecorder()
handler.HandleHWAccel(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var got HWAccelInventory
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.Source != "local" {
t.Fatalf("source = %q, want local fallback when every node probe fails", got.Source)
}
if len(got.Nodes) != 1 || got.Nodes[0].Error == "" {
t.Fatalf("nodes = %+v, want the failing node listed with its error", got.Nodes)
}
}
+18 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/watchsync"
@@ -27,6 +28,15 @@ type WatchProviderHandler struct {
service WatchProviderService
}
type watchProviderDeviceAuthResponse struct {
ID string `json:"id"`
Provider string `json:"provider"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
IntervalSeconds int `json:"interval_seconds"`
ExpiresAt time.Time `json:"expires_at"`
}
func NewWatchProviderHandler(service WatchProviderService) *WatchProviderHandler {
return &WatchProviderHandler{service: service}
}
@@ -97,7 +107,14 @@ func (h *WatchProviderHandler) HandleStartDeviceAuth(w http.ResponseWriter, r *h
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
return
}
writeJSON(w, http.StatusOK, session)
writeJSON(w, http.StatusOK, watchProviderDeviceAuthResponse{
ID: session.ID,
Provider: session.Provider,
UserCode: session.UserCode,
VerificationURL: session.VerificationURL,
IntervalSeconds: session.IntervalSeconds,
ExpiresAt: session.ExpiresAt,
})
}
func (h *WatchProviderHandler) HandlePollDeviceAuth(w http.ResponseWriter, r *http.Request) {
@@ -159,6 +159,11 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
t.Fatalf("response contains Go-style key %q: %#v", key, resp)
}
}
for _, key := range []string{"device_code", "user_id", "profile_id", "completed_at"} {
if _, ok := resp[key]; ok {
t.Fatalf("response exposes host-private field %q: %#v", key, resp)
}
}
if resp["id"] != "auth-1" {
t.Fatalf("id = %#v, want auth-1", resp["id"])
}
+18 -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 {
@@ -2402,6 +2417,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/values", settingValuesHandler.HandleGetValues)
r.Get("/values/effective", settingValuesHandler.HandleGetEffective)
r.Post("/values/effective", settingValuesHandler.HandlePostEffective)
r.Put("/values/nav.shortcuts/item", settingValuesHandler.HandleSetNavigationShortcut)
r.Get("/values/{key}", settingValuesHandler.HandleGetValue)
r.Put("/values/{key}", settingValuesHandler.HandleSetValue)
r.Delete("/values/{key}", settingValuesHandler.HandleDeleteValue)
@@ -2598,9 +2614,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)
})
})
}
@@ -2631,6 +2645,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)
}
+5 -15
View File
@@ -6,24 +6,14 @@ import (
)
var deviceMatchAdjectives = []string{
"amber", "brisk", "calm", "cedar", "clear", "cloud", "copper", "crisp",
"delta", "ember", "fable", "fern", "frost", "gentle", "golden", "harbor",
"hazel", "hollow", "indigo", "ivy", "jade", "juniper", "kindle", "lagoon",
"linen", "lunar", "maple", "meadow", "misty", "navy", "nova", "oak",
"olive", "opal", "orbit", "pepper", "pine", "plum", "prairie", "quiet",
"raven", "river", "rose", "rustic", "sable", "sage", "scarlet", "silver",
"smoky", "solstice", "spruce", "stone", "summer", "sunny", "timber", "topaz",
"velvet", "violet", "willow", "winter", "woodland", "zephyr",
"blue", "busy", "calm", "cozy", "fast", "gold",
"kind", "soft", "tall", "tame", "tiny", "warm",
}
var deviceMatchNouns = []string{
"anchor", "apple", "birch", "brook", "canyon", "castle", "comet", "cove",
"crest", "dawn", "ember", "field", "fjord", "forest", "glade", "grove",
"harbor", "hawk", "hill", "island", "lake", "lantern", "meadow", "mesa",
"moon", "oasis", "ocean", "orchard", "owl", "pine", "planet", "pond",
"quartz", "rain", "reef", "ridge", "river", "rock", "shadow", "shore",
"signal", "snow", "spark", "star", "stone", "stream", "summit", "sun",
"thunder", "trail", "tree", "valley", "wave", "whisper", "wind", "wolf",
"barn", "bell", "cart", "coop", "corn", "cow", "duck", "goat",
"hay", "hen", "lamb", "milk", "oats", "pail", "pond", "pony",
"rake", "shed", "silo", "wool",
}
func randomMatchCode() (string, error) {
+21
View File
@@ -0,0 +1,21 @@
package auth
import "testing"
func TestDeviceMatchCodeWordsStayWithinEightLetters(t *testing.T) {
const maxLetters = 8
const wantCombinations = 240
for _, adjective := range deviceMatchAdjectives {
for _, noun := range deviceMatchNouns {
if got := len(adjective) + len(noun); got > maxLetters {
t.Fatalf("match code %q has %d letters, want at most %d", adjective+" "+noun, got, maxLetters)
}
}
}
// The match phrase is a human confirmation signal, not the login secret,
// but keep enough combinations that accidental collisions remain uncommon.
if combinations := len(deviceMatchAdjectives) * len(deviceMatchNouns); combinations != wantCombinations {
t.Fatalf("match-code word lists have %d combinations, want exactly %d", combinations, wantCombinations)
}
}
@@ -806,14 +806,19 @@ func buildEpisodeCatalogEntryQueryWhere(def QueryDefinition, argIdx int) (string
switch len(groupClauses) {
case 0:
return "", args, argIdx, true, nil
// The returned expression is always parenthesized, mirroring
// QueryBuilder.Build: a match-any group emits a top-level OR, and the
// callers AND library, rating, snapshot, and other access constraints
// onto the result. Without the parentheses, SQL precedence lets an OR
// arm bypass every one of them.
case 1:
return groupClauses[0], args, argIdx, true, nil
return "(" + groupClauses[0] + ")", args, argIdx, true, nil
default:
wrapped := make([]string, len(groupClauses))
for i, clause := range groupClauses {
wrapped[i] = "(" + clause + ")"
}
return strings.Join(wrapped, topJoiner), args, argIdx, true, nil
return "(" + strings.Join(wrapped, topJoiner) + ")", args, argIdx, true, nil
}
}
@@ -230,3 +230,37 @@ func TestExtractEpisodeCatalogUserStatePlanForLastWatched(t *testing.T) {
t.Fatalf("expected genre rule to remain in entry def, got %+v", plan.entryDef.Groups)
}
}
func TestBuildEpisodeCatalogEntryQueryWhereParenthesizesMatchAnyGroup(t *testing.T) {
// Both episode preview paths append this expression to an AND-joined
// whereParts list that already carries the library, content-rating, and
// series-parent-guard constraints. A match-any group emits a top-level OR,
// so the result must come back parenthesized — otherwise SQL precedence
// detaches an OR arm from every one of those constraints.
def := QueryDefinition{
MediaScope: "episode",
Match: "all",
Groups: []QueryGroup{{
Match: "any",
Rules: []QueryRule{
{Field: "genre", Op: "contains", Value: "Action"},
{Field: "genre", Op: "contains", Value: "Adventure"},
},
}},
}.Normalize()
where, args, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 5)
if err != nil {
t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err)
}
if !ok {
t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back")
}
want := "(ece.genres @> ARRAY[$5]::text[] OR ece.genres @> ARRAY[$6]::text[])"
if where != want {
t.Fatalf("WHERE = %q, want %q", where, want)
}
if nextArgIdx != 7 || len(args) != 2 {
t.Fatalf("nextArgIdx = %d, len(args) = %d; want 7, 2", nextArgIdx, len(args))
}
}
+325 -66
View File
@@ -3,6 +3,7 @@ package catalog
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -57,27 +58,63 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex
limit = 20
}
query, args := buildListNextUpQuery(q, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying next-up episodes: %w", err)
}
defer rows.Close()
var results []NextUpResult
for rows.Next() {
var res NextUpResult
if err := rows.Scan(
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
); err != nil {
return nil, fmt.Errorf("scanning next-up row: %w", err)
if q.SeriesID != "" {
query, args := buildListNextUpQuery(q, limit, nil)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying next-up episodes: %w", err)
}
defer rows.Close()
for rows.Next() {
var res NextUpResult
if err := rows.Scan(
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
); err != nil {
return nil, fmt.Errorf("scanning next-up row: %w", err)
}
results = append(results, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating next-up rows: %w", err)
}
} else {
remaining := limit
var cursor *nextUpWalkCursor
batchesRun := 0
exhausted := false
// Every batch resumes below the previous compound frontier, so appending
// batches preserves strictly descending (updated_at, media_item_id) order.
for batch := 0; batch < nextUpAnchorMaxBatches; batch++ {
batchResults, frontier, err := r.listNextUpBatch(ctx, q, remaining, cursor)
if err != nil {
return nil, err
}
batchesRun++
results = append(results, batchResults...)
remaining -= len(batchResults)
if remaining <= 0 {
exhausted = true
break
}
if frontier == nil || frontier.n < nextUpAnchorMaxSeries {
exhausted = true
break
}
cursor = &frontier.nextUpWalkCursor
}
if !exhausted && remaining > 0 {
slog.WarnContext(ctx, "next-up anchor walk hit batch cap; eligible series tail left unscanned",
"component", "catalog",
"profile_id", q.ProfileID,
"batches", batchesRun,
"results_found", len(results))
}
results = append(results, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating next-up rows: %w", err)
}
if q.EnableResumable {
@@ -109,22 +146,82 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex
return results, nil
}
// nextUpAnchorMaxRows bounds how many of the profile's most recent completed
// rows the global next-up query considers when deriving per-series anchors.
// Without a bound the completed_episodes CTE re-reads the profile's entire
// completed history per call — 233k rows joined to episodes for the worst
// bulk-import profile, measured at up to 44.7s. A next-up rail surfaces ~24
// series; the 500 most recently completed episodes cover every series that can
// realistically rank on it (a series whose last completed episode is older
// than 500 completions of other content sorts far past the rail's limit).
// Progress rows are recency-ordered on idx_uwp_profile_completed, so the
// bounded anchor scan is an ordered index walk. Series-scoped calls (the
// show-detail tile) stay unbounded: they must anchor on the series' last
// completed episode no matter how long ago it was watched, and are naturally
// bounded by one series.
const nextUpAnchorMaxRows = 500
type nextUpWalkCursor struct {
updatedAt time.Time
mediaItemID string
seen []string
}
func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
type nextUpWalkFrontier struct {
nextUpWalkCursor
n int
}
// nextUpAnchorMaxSeries bounds the number of distinct series anchors visited by
// one global next-up batch. A row-based cap let one series consume the window;
// counting distinct series prevents that, while batching continues past a full
// batch whose anchors are all ineligible. Series-scoped calls stay unbounded so
// they can anchor on the series' last completed episode regardless of age.
const nextUpAnchorMaxSeries = 96
// nextUpAnchorMaxBatches bounds a global call to 10 × 96 = 960 visited series
// as a runaway guard.
const nextUpAnchorMaxBatches = 10
func (r *NextUpRepository) listNextUpBatch(
ctx context.Context,
q NextUpQuery,
limit int,
cursor *nextUpWalkCursor,
) ([]NextUpResult, *nextUpWalkFrontier, error) {
query, args := buildListNextUpQuery(q, limit, cursor)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, nil, fmt.Errorf("querying next-up episodes: %w", err)
}
defer rows.Close()
var results []NextUpResult
var frontier *nextUpWalkFrontier
for rows.Next() {
currentFrontier := nextUpWalkFrontier{}
var contentID, seriesID, seriesTitle *string
var seasonNumber, episodeNumber *int
var completedAt *time.Time
if err := rows.Scan(
&currentFrontier.updatedAt,
&currentFrontier.mediaItemID,
&currentFrontier.seen,
&currentFrontier.n,
&contentID,
&seriesID,
&seriesTitle,
&seasonNumber,
&episodeNumber,
&completedAt,
); err != nil {
return nil, nil, fmt.Errorf("scanning next-up row: %w", err)
}
frontier = &currentFrontier
if contentID == nil {
continue
}
results = append(results, NextUpResult{
ContentID: *contentID,
SeriesID: *seriesID,
SeriesTitle: *seriesTitle,
SeasonNumber: *seasonNumber,
EpisodeNumber: *episodeNumber,
CompletedAt: *completedAt,
})
}
if err := rows.Err(); err != nil {
return nil, nil, fmt.Errorf("iterating next-up rows: %w", err)
}
return results, frontier, nil
}
func buildListNextUpQuery(q NextUpQuery, limit int, cursor *nextUpWalkCursor) (string, []interface{}) {
args := []interface{}{q.UserID, q.ProfileID, limit}
argIdx := 4
@@ -142,6 +239,21 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
argIdx++
}
// seedSeen is projected alongside the seed's picked series, which the global
// walk exposes as `pick`; the series-scoped branch has no walk and no seed.
cursorFilter := ""
seedSeen := "ARRAY[pick.series_id]"
if q.SeriesID == "" && cursor != nil {
cursorUpdatedAtArg := argIdx
cursorMediaItemIDArg := argIdx + 1
cursorSeenArg := argIdx + 2
cursorFilter = fmt.Sprintf(`
AND (uwp.updated_at, uwp.media_item_id) < ($%d, $%d)
AND NOT (e.series_id = ANY($%d))`, cursorUpdatedAtArg, cursorMediaItemIDArg, cursorSeenArg)
seedSeen = fmt.Sprintf("$%d::text[] || pick.series_id", cursorSeenArg)
args = append(args, cursor.updatedAt, cursor.mediaItemID, cursor.seen)
}
// When resumable items are disabled, suppress next-up only if the series has
// newer in-progress activity than the most recent completed episode. Older
// partial watches should not block the user's current progression.
@@ -169,11 +281,16 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
sourceTable = "eligible_series"
}
// Global queries derive series anchors from a bounded recent-completions
// scan (see nextUpAnchorMaxRows); series-scoped queries keep the unbounded
// shape so the anchor is the series' last completed episode regardless of
// age. The hidden-items exclusion and date cutoff apply inside the bounded
// scan so hidden/old rows never consume the anchor budget.
// Global queries use a recursive loose-index scan to find at most
// nextUpAnchorMaxSeries distinct series without letting one series' rows
// consume the budget. The compound cursor is the total order required when
// bulk updates give many rows the same timestamp; because media_item_id is
// unique it can only order the walk, never decide which episode of a series
// anchors it — see the anchor lateral below.
// Series-scoped queries keep the unbounded shape so the anchor is the
// series' last completed episode regardless of age. Hidden rows and the date
// cutoff are excluded inside every walk step so they neither become anchors
// nor advance the cursor.
var completedEpisodesCTE string
if q.SeriesID != "" {
completedEpisodesCTE = fmt.Sprintf(`completed_episodes AS (
@@ -200,37 +317,121 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC
)`, seriesFilter, dateCutoffFilter)
} else {
completedEpisodesCTE = fmt.Sprintf(`recent_completed AS (
SELECT uwp.media_item_id, uwp.updated_at
FROM user_watch_progress uwp
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = uwp.user_id
AND hhi.profile_id = uwp.profile_id
AND hhi.media_item_id = uwp.media_item_id
AND uwp.updated_at <= hhi.hidden_before
)
%s
ORDER BY uwp.updated_at DESC
LIMIT %d
// Picking the series and picking which of its episodes to anchor on are
// two different orderings, so they are two different steps.
//
// The walk advances on (updated_at, media_item_id), the total order that
// guarantees forward progress. media_item_id is unique, so no two rows
// ever tie on that pair and any season/episode clause appended behind it
// is unreachable. A bulk mark-watched series — every row written with one
// timestamp — would then anchor on whichever content_id sorts highest,
// which is an arbitrary season once IDs are not scan-ordered (season 2
// scanned before a season-1 backfill), and the rail would surface an
// already-watched episode's successor.
//
// So `pick` chooses the series and the cursor position, and the anchor
// lateral then chooses the episode by (season, episode) among that
// series' rows sharing pick.updated_at — its newest completed timestamp.
// Pinning updated_at rather than re-sorting keeps this an index probe,
// and both rows carry the same updated_at, so cursor order and the
// reported CompletedAt are unchanged by the split.
anchorLateral := `JOIN LATERAL (
SELECT e_a.season_number, e_a.episode_number
FROM episodes e_a
JOIN user_watch_progress uwp_a
ON uwp_a.media_item_id = e_a.content_id
AND uwp_a.user_id = $1
AND uwp_a.profile_id = $2
AND uwp_a.completed = TRUE
AND uwp_a.updated_at = pick.updated_at
WHERE e_a.series_id = pick.series_id
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = uwp_a.user_id
AND hhi.profile_id = uwp_a.profile_id
AND hhi.media_item_id = uwp_a.media_item_id
AND uwp_a.updated_at <= hhi.hidden_before
)
ORDER BY e_a.season_number DESC, e_a.episode_number DESC
LIMIT 1
) anchor ON TRUE`
completedEpisodesCTE = fmt.Sprintf(`RECURSIVE walk AS (
(
SELECT
pick.series_id,
anchor.season_number,
anchor.episode_number,
pick.updated_at,
pick.media_item_id,
%s AS seen,
1 AS n
FROM (
SELECT e.series_id, uwp.updated_at, uwp.media_item_id
FROM user_watch_progress uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = uwp.user_id
AND hhi.profile_id = uwp.profile_id
AND hhi.media_item_id = uwp.media_item_id
AND uwp.updated_at <= hhi.hidden_before
)
%s
%s
ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC
LIMIT 1
) pick
%s
)
UNION ALL
SELECT
pick.series_id,
anchor.season_number,
anchor.episode_number,
pick.updated_at,
pick.media_item_id,
w.seen || pick.series_id,
w.n + 1
FROM walk w
JOIN LATERAL (
SELECT e.series_id, uwp.updated_at, uwp.media_item_id
FROM user_watch_progress uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND (uwp.updated_at, uwp.media_item_id) < (w.updated_at, w.media_item_id)
AND NOT (e.series_id = ANY(w.seen))
AND NOT EXISTS (
SELECT 1
FROM user_history_hidden_items hhi
WHERE hhi.user_id = uwp.user_id
AND hhi.profile_id = uwp.profile_id
AND hhi.media_item_id = uwp.media_item_id
AND uwp.updated_at <= hhi.hidden_before
)
%s
ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC
LIMIT 1
) pick ON true
%s
WHERE w.n < %d
),
completed_episodes AS (
SELECT DISTINCT ON (e.series_id)
e.series_id,
e.season_number,
e.episode_number,
uwp.updated_at
FROM recent_completed uwp
JOIN episodes e ON e.content_id = uwp.media_item_id
ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC
)`, dateCutoffFilter, nextUpAnchorMaxRows)
SELECT series_id, season_number, episode_number, updated_at
FROM walk
)`, seedSeen, cursorFilter, dateCutoffFilter, anchorLateral,
dateCutoffFilter, anchorLateral, nextUpAnchorMaxSeries)
}
query := fmt.Sprintf(`
if q.SeriesID != "" {
query := fmt.Sprintf(`
WITH %s
%s
SELECT
@@ -260,6 +461,64 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
) next_ep ON true
ORDER BY es.updated_at DESC
LIMIT $3`, completedEpisodesCTE, inProgressExclusion, sourceTable)
return query, args
}
resultQuery := fmt.Sprintf(`
SELECT
next_ep.content_id,
next_ep.series_id,
si.title,
next_ep.season_number,
next_ep.episode_number,
es.updated_at AS completed_at
FROM %s es
JOIN media_items si ON si.content_id = es.series_id
JOIN LATERAL (
SELECT e2.content_id, e2.series_id, e2.season_number, e2.episode_number
FROM episodes e2
WHERE e2.series_id = es.series_id
AND (e2.season_number, e2.episode_number) > (es.season_number, es.episode_number)
AND EXISTS (SELECT 1 FROM media_files mf WHERE mf.episode_id = e2.content_id AND mf.missing_since IS NULL)
AND NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp2
WHERE uwp2.user_id = $1
AND uwp2.profile_id = $2
AND uwp2.media_item_id = e2.content_id
AND (uwp2.completed = TRUE OR uwp2.position_seconds > 0)
)
ORDER BY e2.season_number, e2.episode_number
LIMIT 1
) next_ep ON true
ORDER BY es.updated_at DESC
LIMIT $3`, sourceTable)
query := fmt.Sprintf(`
WITH %s
%s
,
frontier AS (
SELECT w.updated_at, w.media_item_id, w.seen, w.n
FROM walk w
ORDER BY w.n DESC
LIMIT 1
)
SELECT
f.updated_at AS frontier_updated_at,
f.media_item_id AS frontier_media_item_id,
f.seen AS frontier_seen,
f.n AS frontier_n,
r.content_id,
r.series_id,
r.title,
r.season_number,
r.episode_number,
r.completed_at
FROM frontier f
LEFT JOIN (
%s
) r ON true
ORDER BY r.completed_at DESC NULLS LAST`, completedEpisodesCTE, inProgressExclusion, resultQuery)
return query, args
}
+574 -14
View File
@@ -1,18 +1,58 @@
package catalog
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type nextUpTestStoreProvider struct{}
func (nextUpTestStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
return nil, nil
}
func (nextUpTestStoreProvider) Close() error { return nil }
func newNextUpTestPool(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.media_items')::text`).Scan(&tableName); err != nil {
t.Fatalf("check media_items table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied base schema")
}
return pool
}
func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *testing.T) {
t.Parallel()
query, args := buildListNextUpQuery(NextUpQuery{
UserID: 7,
ProfileID: "profile-1",
}, 20)
}, 20, nil)
expectedFragments := []string{
"eligible_series AS (",
@@ -22,7 +62,7 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *
"FROM user_history_hidden_items hhi",
"uwp.updated_at <= hhi.hidden_before",
"AND (uwp2.completed = TRUE OR uwp2.position_seconds > 0)",
"ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC",
"ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC",
"LIMIT $3",
}
for _, fragment := range expectedFragments {
@@ -43,29 +83,100 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *
func TestBuildListNextUpQuery_GlobalBoundsAnchorScan(t *testing.T) {
t.Parallel()
query, _ := buildListNextUpQuery(NextUpQuery{
q := NextUpQuery{
UserID: 7,
ProfileID: "profile-1",
}, 20)
// The global query must derive series anchors from a bounded
// recent-completions scan, not the profile's entire completed history.
if !strings.Contains(query, "recent_completed AS (") {
t.Fatalf("expected global query to scan recent completions, got:\n%s", query)
}
if !strings.Contains(query, fmt.Sprintf("LIMIT %d", nextUpAnchorMaxRows)) {
t.Fatalf("expected global anchor scan bounded at %d rows, got:\n%s", nextUpAnchorMaxRows, query)
query, args := buildListNextUpQuery(q, 20, nil)
expectedFragments := []string{
"WITH RECURSIVE",
"(uwp.updated_at, uwp.media_item_id) < (w.updated_at, w.media_item_id)",
"NOT (e.series_id = ANY(w.seen))",
fmt.Sprintf("w.n < %d", nextUpAnchorMaxSeries),
"frontier AS (",
"LEFT JOIN (",
}
for _, fragment := range expectedFragments {
if !strings.Contains(query, fragment) {
t.Fatalf("expected global anchor walk to contain %q, got:\n%s", fragment, query)
}
}
// Seed step, recursive step, and the anchor lateral attached to each.
if got := strings.Count(query, "FROM user_history_hidden_items hhi"); got != 4 {
t.Fatalf("expected hidden-items filter in every walk step and anchor, got %d occurrences:\n%s", got, query)
}
seed := strings.SplitN(query, "UNION ALL", 2)[0]
if strings.Contains(seed, "(uwp.updated_at, uwp.media_item_id) < ($") {
t.Fatalf("first batch seed must not contain a continuation cursor, got:\n%s", seed)
}
if strings.Contains(seed, "e.series_id = ANY($") {
t.Fatalf("first batch seed must not contain a seen-series exclusion, got:\n%s", seed)
}
if len(args) != 3 {
t.Fatalf("expected first batch args only, got %v", args)
}
cursorAt := time.Now().UTC()
cursor := &nextUpWalkCursor{
updatedAt: cursorAt,
mediaItemID: "episode-96",
seen: []string{"series-1", "series-2"},
}
continuedQuery, continuedArgs := buildListNextUpQuery(q, 7, cursor)
continuedSeed := strings.SplitN(continuedQuery, "UNION ALL", 2)[0]
continuedFragments := []string{
"(uwp.updated_at, uwp.media_item_id) < ($4, $5)",
"NOT (e.series_id = ANY($6))",
"$6::text[] || pick.series_id AS seen",
"frontier AS (",
"LEFT JOIN (",
}
for _, fragment := range continuedFragments {
if !strings.Contains(continuedQuery, fragment) {
t.Fatalf("expected continued query to contain %q, got:\n%s", fragment, continuedQuery)
}
}
if !strings.Contains(continuedSeed, "(uwp.updated_at, uwp.media_item_id) < ($4, $5)") ||
!strings.Contains(continuedSeed, "NOT (e.series_id = ANY($6))") {
t.Fatalf("continued batch seed must contain cursor and seen-series predicates, got:\n%s", continuedSeed)
}
if len(continuedArgs) != 6 || continuedArgs[3] != cursorAt || continuedArgs[4] != cursor.mediaItemID {
t.Fatalf("unexpected continuation args: %v", continuedArgs)
}
seenArg, ok := continuedArgs[5].([]string)
if !ok || len(seenArg) != len(cursor.seen) || seenArg[0] != cursor.seen[0] || seenArg[1] != cursor.seen[1] {
t.Fatalf("expected cursor seen array verbatim, got %#v", continuedArgs[5])
}
}
func TestBuildListNextUpQuery_GlobalDateCutoffAppliesToEveryWalkStep(t *testing.T) {
t.Parallel()
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
query, args := buildListNextUpQuery(NextUpQuery{
UserID: 7,
ProfileID: "profile-1",
DateCutoff: &cutoff,
}, 20, nil)
if got := strings.Count(query, "AND uwp.updated_at >= $4"); got != 2 {
t.Fatalf("expected date cutoff in seed and recursive step, got %d occurrences:\n%s", got, query)
}
if len(args) != 4 || args[3] != cutoff {
t.Fatalf("expected one shared date-cutoff arg, got %v", args)
}
}
func TestBuildListNextUpQuery_SeriesScopedKeepsUnboundedAnchor(t *testing.T) {
t.Parallel()
query, args := buildListNextUpQuery(NextUpQuery{
q := NextUpQuery{
UserID: 7,
ProfileID: "profile-1",
SeriesID: "series-42",
}, 20)
}
query, args := buildListNextUpQuery(q, 20, nil)
// The show-detail tile must anchor on the series' last completed episode
// no matter how long ago it was watched: the recency bound would make a
@@ -73,12 +184,461 @@ func TestBuildListNextUpQuery_SeriesScopedKeepsUnboundedAnchor(t *testing.T) {
if strings.Contains(query, "recent_completed AS (") {
t.Fatalf("series-scoped query must not bound the anchor scan, got:\n%s", query)
}
if strings.Contains(query, "WITH RECURSIVE") {
t.Fatalf("series-scoped query must keep its non-recursive SQL shape, got:\n%s", query)
}
if !strings.Contains(query, "AND e.series_id = $4") {
t.Fatalf("series-scoped query must filter by series_id, got:\n%s", query)
}
if len(args) != 4 {
t.Fatalf("expected 4 args with SeriesID, got %d (%v)", len(args), args)
}
queryWithCursor, argsWithCursor := buildListNextUpQuery(q, 20, &nextUpWalkCursor{
updatedAt: time.Now().UTC(),
mediaItemID: "ignored-episode",
seen: []string{"ignored-series"},
})
if queryWithCursor != query || fmt.Sprint(argsWithCursor) != fmt.Sprint(args) {
t.Fatalf("series-scoped SQL and args must be byte-identical when a walk cursor is supplied")
}
}
func TestBuildListNextUpQuery_GlobalAnchorOrdersByEpisodeNotMediaItemID(t *testing.T) {
t.Parallel()
// media_item_id is unique, so a season/episode tie-break appended behind it
// can never be reached. Choosing the series and choosing which of its
// episodes anchors the rail have to be separate orderings.
query, _ := buildListNextUpQuery(NextUpQuery{
UserID: 7,
ProfileID: "profile-1",
}, 20, nil)
if strings.Contains(query, `ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC,
e.season_number DESC, e.episode_number DESC`) {
t.Fatalf("season/episode must not sit behind the unique media_item_id tie-break, got:\n%s", query)
}
if got := strings.Count(query, "ORDER BY e_a.season_number DESC, e_a.episode_number DESC"); got != 2 {
t.Fatalf("expected episode-ordered anchor in seed and recursive step, got %d occurrences:\n%s", got, query)
}
if got := strings.Count(query, "AND uwp_a.updated_at = pick.updated_at"); got != 2 {
t.Fatalf("expected anchor pinned to the series' newest completed timestamp, got %d:\n%s", got, query)
}
}
func TestNextUpRepository_BulkMarkWatchedAnchorsOnHighestEpisode(t *testing.T) {
// A bulk mark-watched series writes every row with one timestamp. The anchor
// must then be the highest (season, episode), not the highest content_id:
// with production-shape IDs where season 2 was scanned before a season-1
// backfill, season 1 sorts higher and the rail would surface s01e04 — an
// episode after one the user already watched — instead of s02e04.
pool := newNextUpTestPool(t)
ctx := context.Background()
prefix := fmt.Sprintf("nextup-order-%d", time.Now().UnixNano())
seriesID := prefix + "-series"
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, seriesID)
})
seedNextUpSeries(t, ctx, pool, seriesID, prefix+" Ordering")
// Season 2 scanned first (lower IDs), season 1 backfilled later (higher IDs).
const wantContentID = "1" + "00000000000000004" // s02e04
const trapContentID = "9" + "00000000000000004" // s01e04
episodeIDs := []string{
"100000000000000001", "100000000000000002", "100000000000000003", wantContentID,
"900000000000000001", "900000000000000002", "900000000000000003", trapContentID,
}
seasons := []int{2, 2, 2, 2, 1, 1, 1, 1}
numbers := []int{1, 2, 3, 4, 1, 2, 3, 4}
for i, episodeID := range episodeIDs {
if _, err := pool.Exec(ctx, `
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
VALUES ($1, $2, $3, $4, 'Ordering Episode')
`, episodeID, seriesID, seasons[i], numbers[i]); err != nil {
t.Fatalf("seed episode %s: %v", episodeID, err)
}
}
seedNextUpFiles(t, ctx, pool, folderID, episodeIDs)
// Every completed row shares one timestamp, as a bulk mark-watched write does.
bulkAt := time.Now().UTC().Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
SELECT $1, $2, unnest($3::text[]), 0, 1800, TRUE, $4
`, userID, profileID, []string{
"100000000000000001", "100000000000000002", "100000000000000003",
"900000000000000001", "900000000000000002", "900000000000000003",
}, bulkAt); err != nil {
t.Fatalf("seed bulk progress: %v", err)
}
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
results, err := repo.ListNextUp(ctx, NextUpQuery{
UserID: userID,
ProfileID: profileID,
Limit: 20,
EnableResumable: false,
})
if err != nil {
t.Fatalf("ListNextUp: %v", err)
}
assertNextUpContentIDs(t, results, wantContentID)
}
func TestNextUpRepository_GlobalAnchorWalkSurvivesFloodedSeries(t *testing.T) {
pool := newNextUpTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
prefix := fmt.Sprintf("nextup-flood-%d", suffix)
seriesA := prefix + "-series-a"
seriesB := prefix + "-series-b"
seriesC := prefix + "-series-c"
seriesIDs := []string{seriesA, seriesB, seriesC}
episodeAPrefix := prefix + "-episode-a-"
episodeB1 := prefix + "-episode-b-1"
episodeB2 := prefix + "-episode-b-2"
episodeC1 := prefix + "-episode-c-1"
episodeC2 := prefix + "-episode-c-2"
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
})
seedNextUpSeries(t, ctx, pool, seriesA, prefix+" Series A")
seedNextUpSeries(t, ctx, pool, seriesB, prefix+" Series B")
seedNextUpSeries(t, ctx, pool, seriesC, prefix+" Series C")
if _, err := pool.Exec(ctx, `
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
SELECT $1 || gs::text, $2, 1, gs, 'Flood Episode ' || gs::text
FROM generate_series(1, 600) gs
`, episodeAPrefix, seriesA); err != nil {
t.Fatalf("seed flooded episodes: %v", err)
}
seedNextUpEpisodes(t, ctx, pool,
[]string{episodeB1, episodeB2, episodeC1, episodeC2},
[]string{seriesB, seriesB, seriesC, seriesC},
[]int{1, 2, 1, 2},
)
seedNextUpFiles(t, ctx, pool, folderID, []string{episodeB2, episodeC2})
floodedAt := time.Now().UTC().Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
SELECT $1, $2, $3 || gs::text, 0, 1800, TRUE, $4
FROM generate_series(1, 600) gs
`, userID, profileID, episodeAPrefix, floodedAt); err != nil {
t.Fatalf("seed flooded progress: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
VALUES ($1, $2, $3, 0, 1800, TRUE, $5),
($1, $2, $4, 0, 1800, TRUE, $6)
`, userID, profileID, episodeB1, episodeC1, floodedAt.Add(-time.Hour), floodedAt.Add(-2*time.Hour)); err != nil {
t.Fatalf("seed older series progress: %v", err)
}
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
results, err := repo.ListNextUp(ctx, NextUpQuery{
UserID: userID,
ProfileID: profileID,
Limit: 20,
EnableResumable: false,
})
if err != nil {
t.Fatalf("ListNextUp: %v", err)
}
assertNextUpContentIDs(t, results, episodeB2, episodeC2)
}
func TestNextUpRepository_GlobalAnchorWalkKeepsSameTimestampSeries(t *testing.T) {
pool := newNextUpTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
prefix := fmt.Sprintf("nextup-cursor-%d", suffix)
seriesD := prefix + "-series-d"
seriesE := prefix + "-series-e"
seriesIDs := []string{seriesD, seriesE}
episodeD1 := prefix + "-episode-d-1"
episodeD2 := prefix + "-episode-d-2"
episodeE1 := prefix + "-episode-e-1"
episodeE2 := prefix + "-episode-e-2"
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
})
seedNextUpSeries(t, ctx, pool, seriesD, prefix+" Series D")
seedNextUpSeries(t, ctx, pool, seriesE, prefix+" Series E")
seedNextUpEpisodes(t, ctx, pool,
[]string{episodeD1, episodeD2, episodeE1, episodeE2},
[]string{seriesD, seriesD, seriesE, seriesE},
[]int{1, 2, 1, 2},
)
seedNextUpFiles(t, ctx, pool, folderID, []string{episodeD2, episodeE2})
completedAt := time.Now().UTC().Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
VALUES ($1, $2, $3, 0, 1800, TRUE, $5),
($1, $2, $4, 0, 1800, TRUE, $5)
`, userID, profileID, episodeD1, episodeE1, completedAt); err != nil {
t.Fatalf("seed same-timestamp progress: %v", err)
}
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
results, err := repo.ListNextUp(ctx, NextUpQuery{
UserID: userID,
ProfileID: profileID,
Limit: 20,
EnableResumable: false,
})
if err != nil {
t.Fatalf("ListNextUp: %v", err)
}
assertNextUpContentIDs(t, results, episodeD2, episodeE2)
}
func TestNextUpRepository_GlobalAnchorWalkContinuesPastIneligibleSeries(t *testing.T) {
pool := newNextUpTestPool(t)
ctx := context.Background()
prefix := fmt.Sprintf("nextup-ineligible-%d", time.Now().UnixNano())
const ineligibleCount = 100
seriesIDs := make([]string, 0, ineligibleCount+2)
episodeIDs := make([]string, 0, ineligibleCount+4)
episodeSeriesIDs := make([]string, 0, ineligibleCount+4)
episodeNumbers := make([]int, 0, ineligibleCount+4)
completedEpisodeIDs := make([]string, 0, ineligibleCount+2)
completedTimes := make([]time.Time, 0, ineligibleCount+2)
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
})
newest := time.Now().UTC().Truncate(time.Microsecond)
for i := 0; i < ineligibleCount; i++ {
seriesID := fmt.Sprintf("%s-series-%03d", prefix, i)
episodeID := seriesID + "-episode-1"
seriesIDs = append(seriesIDs, seriesID)
episodeIDs = append(episodeIDs, episodeID)
episodeSeriesIDs = append(episodeSeriesIDs, seriesID)
episodeNumbers = append(episodeNumbers, 1)
completedEpisodeIDs = append(completedEpisodeIDs, episodeID)
completedTimes = append(completedTimes, newest.Add(-time.Duration(i)*time.Minute))
seedNextUpSeries(t, ctx, pool, seriesID, fmt.Sprintf("%s Ineligible %03d", prefix, i))
}
olderSeriesA := prefix + "-older-series-a"
olderSeriesB := prefix + "-older-series-b"
olderA1 := olderSeriesA + "-episode-1"
olderA2 := olderSeriesA + "-episode-2"
olderB1 := olderSeriesB + "-episode-1"
olderB2 := olderSeriesB + "-episode-2"
seriesIDs = append(seriesIDs, olderSeriesA, olderSeriesB)
seedNextUpSeries(t, ctx, pool, olderSeriesA, prefix+" Older A")
seedNextUpSeries(t, ctx, pool, olderSeriesB, prefix+" Older B")
episodeIDs = append(episodeIDs, olderA1, olderA2, olderB1, olderB2)
episodeSeriesIDs = append(episodeSeriesIDs, olderSeriesA, olderSeriesA, olderSeriesB, olderSeriesB)
episodeNumbers = append(episodeNumbers, 1, 2, 1, 2)
completedEpisodeIDs = append(completedEpisodeIDs, olderA1, olderB1)
completedTimes = append(completedTimes,
newest.Add(-(ineligibleCount+1)*time.Minute),
newest.Add(-(ineligibleCount+2)*time.Minute),
)
seedNextUpEpisodes(t, ctx, pool, episodeIDs, episodeSeriesIDs, episodeNumbers)
seedNextUpFiles(t, ctx, pool, folderID, []string{olderA2, olderB2})
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
SELECT $1, $2, episode_id, 0, 1800, TRUE, completed_at
FROM unnest($3::text[], $4::timestamptz[]) AS fixture(episode_id, completed_at)
`, userID, profileID, completedEpisodeIDs, completedTimes); err != nil {
t.Fatalf("seed ineligible-flood progress: %v", err)
}
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
results, err := repo.ListNextUp(ctx, NextUpQuery{
UserID: userID,
ProfileID: profileID,
Limit: 20,
EnableResumable: false,
})
if err != nil {
t.Fatalf("ListNextUp: %v", err)
}
assertNextUpContentIDs(t, results, olderA2, olderB2)
}
func TestNextUpRepository_GlobalAnchorWalkStopsWhenHistoryExhausted(t *testing.T) {
pool := newNextUpTestPool(t)
ctx := context.Background()
prefix := fmt.Sprintf("nextup-exhausted-%d", time.Now().UnixNano())
seriesIDs := []string{
prefix + "-series-a",
prefix + "-series-b",
prefix + "-series-c",
}
episodeIDs := []string{
seriesIDs[0] + "-episode-1",
seriesIDs[1] + "-episode-1",
seriesIDs[2] + "-episode-1",
}
userID, profileID, _ := seedNextUpTestOwner(t, ctx, pool, prefix)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
})
for i, seriesID := range seriesIDs {
seedNextUpSeries(t, ctx, pool, seriesID, fmt.Sprintf("%s Finished %d", prefix, i))
}
seedNextUpEpisodes(t, ctx, pool, episodeIDs, seriesIDs, []int{1, 1, 1})
completedAt := time.Now().UTC().Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds,
duration_seconds, completed, updated_at
)
SELECT $1, $2, episode_id, 0, 1800, TRUE, $4::timestamptz - (ordinality * INTERVAL '1 minute')
FROM unnest($3::text[]) WITH ORDINALITY AS fixture(episode_id, ordinality)
`, userID, profileID, episodeIDs, completedAt); err != nil {
t.Fatalf("seed exhausted progress: %v", err)
}
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
results, err := repo.ListNextUp(ctx, NextUpQuery{
UserID: userID,
ProfileID: profileID,
Limit: 20,
EnableResumable: false,
})
if err != nil {
t.Fatalf("ListNextUp: %v", err)
}
if len(results) != 0 {
t.Fatalf("ListNextUp returned %d rows after exhausting finished history: %+v", len(results), results)
}
}
func seedNextUpTestOwner(t *testing.T, ctx context.Context, pool *pgxpool.Pool, prefix string) (int, string, int) {
t.Helper()
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled)
VALUES ('series', $1, TRUE)
RETURNING id
`, prefix+" Library").Scan(&folderID); err != nil {
t.Fatalf("seed media folder: %v", err)
}
var userID int
if err := pool.QueryRow(ctx, `
INSERT INTO users (username, role)
VALUES ($1, 'user')
RETURNING id
`, prefix+"-user").Scan(&userID); err != nil {
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
t.Fatalf("seed user: %v", err)
}
profileID := fmt.Sprintf("00000000-0000-4000-8000-%012d", time.Now().UnixNano()%1_000_000_000_000)
if _, err := pool.Exec(ctx, `
INSERT INTO user_profiles (id, user_id, name)
VALUES ($1, $2, 'Next Up Regression')
`, profileID, userID); err != nil {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
t.Fatalf("seed profile: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
return userID, profileID, folderID
}
func seedNextUpSeries(t *testing.T, ctx context.Context, pool *pgxpool.Pool, seriesID, title string) {
t.Helper()
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres)
VALUES ($1, 'series', $2, 'matched', '{}'::text[])
`, seriesID, title); err != nil {
t.Fatalf("seed series %s: %v", seriesID, err)
}
}
func seedNextUpEpisodes(
t *testing.T,
ctx context.Context,
pool *pgxpool.Pool,
episodeIDs []string,
seriesIDs []string,
episodeNumbers []int,
) {
t.Helper()
if _, err := pool.Exec(ctx, `
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
SELECT episode_id, series_id, 1, episode_number, 'Next Up Episode ' || episode_number::text
FROM unnest($1::text[], $2::text[], $3::int[]) AS fixture(episode_id, series_id, episode_number)
`, episodeIDs, seriesIDs, episodeNumbers); err != nil {
t.Fatalf("seed episodes: %v", err)
}
}
func seedNextUpFiles(t *testing.T, ctx context.Context, pool *pgxpool.Pool, folderID int, episodeIDs []string) {
t.Helper()
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (episode_id, media_folder_id, file_path, duration)
SELECT episode_id, $2, '/nextup-regression/' || episode_id || '.mkv', 1800
FROM unnest($1::text[]) AS fixture(episode_id)
`, episodeIDs, folderID); err != nil {
t.Fatalf("seed media files: %v", err)
}
}
func assertNextUpContentIDs(t *testing.T, results []NextUpResult, want ...string) {
t.Helper()
if len(results) != len(want) {
t.Fatalf("ListNextUp returned %d rows, want %d: %+v", len(results), len(want), results)
}
got := make(map[string]bool, len(results))
for _, result := range results {
got[result.ContentID] = true
}
for _, contentID := range want {
if !got[contentID] {
t.Errorf("ListNextUp missing %s: %+v", contentID, results)
}
}
}
func TestBuildListNextUpQuery_EnableResumableSkipsSeriesSuppressionCTE(t *testing.T) {
@@ -88,7 +648,7 @@ func TestBuildListNextUpQuery_EnableResumableSkipsSeriesSuppressionCTE(t *testin
UserID: 7,
ProfileID: "profile-1",
EnableResumable: true,
}, 20)
}, 20, nil)
if strings.Contains(query, "eligible_series AS (") {
t.Fatalf("expected resumable query to skip eligible_series suppression CTE, got:\n%s", query)
+7 -2
View File
@@ -100,15 +100,20 @@ func (qb *QueryBuilder) Build(input any) (string, []any, error) {
if len(groupClauses) == 0 {
return "", nil, nil
}
// The returned expression is always parenthesized so it stays atomic when
// callers AND access, library, and other outer constraints onto it. A
// match-any group (or a match-any top level) emits a top-level OR; without
// the parentheses, SQL precedence lets the first OR arm bypass every
// constraint appended afterward.
if len(groupClauses) == 1 {
return groupClauses[0], qb.args, nil
return "(" + groupClauses[0] + ")", qb.args, nil
}
wrapped := make([]string, len(groupClauses))
for i, clause := range groupClauses {
wrapped[i] = "(" + clause + ")"
}
return strings.Join(wrapped, topJoiner), qb.args, nil
return "(" + strings.Join(wrapped, topJoiner) + ")", qb.args, nil
}
func normalizeBuilderInput(input any) (QueryDefinition, error) {
+51
View File
@@ -1048,3 +1048,54 @@ func TestUserHistoryCTESQLIncludesCompletedEbookReaderProgress(t *testing.T) {
}
}
}
func TestBuildParenthesizesResultForSafeANDComposition(t *testing.T) {
// Build's callers (query_executor.go, handlers/catalog.go) AND library and
// access-filter constraints onto the returned expression. A match-any
// group emits a top-level OR, so the result must come back parenthesized —
// otherwise SQL precedence lets the first OR arm bypass every constraint
// appended afterward.
tests := []struct {
name string
def QueryDefinition
want string
}{
{
name: "single match-any group",
def: QueryDefinition{
Match: "all",
Groups: []QueryGroup{{
Match: "any",
Rules: []QueryRule{
{Field: "genre", Op: "contains", Value: "Action"},
{Field: "genre", Op: "contains", Value: "Adventure"},
},
}},
},
want: "(mi.genres @> ARRAY[$1]::text[] OR mi.genres @> ARRAY[$2]::text[])",
},
{
name: "match-any top level",
def: QueryDefinition{
Match: "any",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "genre", Op: "contains", Value: "Action"}}},
{Match: "all", Rules: []QueryRule{{Field: "genre", Op: "contains", Value: "Adventure"}}},
},
},
want: "((mi.genres @> ARRAY[$1]::text[]) OR (mi.genres @> ARRAY[$2]::text[]))",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clause, _, err := NewQueryBuilder("mi").Build(tt.def)
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if clause != tt.want {
t.Fatalf("Build clause = %q, want %q", clause, tt.want)
}
})
}
}
+3
View File
@@ -281,6 +281,9 @@ func (e *QueryExecutor) buildPreviewPagePlan(
argIdx := builder.ArgIdx() + filterArgOffset
if filterWhere != "" {
// QueryBuilder.Build returns a parenthesized expression, so AND-ing
// access, library, and other outer constraints onto it cannot let a
// match-any OR arm escape them.
conditions = append(conditions, filterWhere)
}
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
@@ -0,0 +1,40 @@
package catalog
import (
"reflect"
"slices"
"strings"
"testing"
)
func TestQueryExecutorGroupQueryPreservesAccessFilters(t *testing.T) {
executor := &QueryExecutor{}
def := QueryDefinition{
Match: "all",
LibraryIDs: []int{42},
Groups: []QueryGroup{{
Match: "any",
Rules: []QueryRule{
{Field: "genre", Op: "contains", Value: "Action"},
{Field: "genre", Op: "contains", Value: "Adventure"},
},
}},
}
sql, args, err := executor.buildPreviewPageSQL(def, AccessFilter{MaxContentRating: "PG"}, 20, 0, false)
if err != nil {
t.Fatalf("build preview SQL: %v", err)
}
groupedFilter := "WHERE (mi.genres @> ARRAY[$1]::text[] OR mi.genres @> ARRAY[$2]::text[]) AND EXISTS"
if !strings.Contains(sql, groupedFilter) {
t.Fatalf("group predicate is not isolated from access filters:\n%s", sql)
}
if len(args) != 6 || args[0] != "Action" || args[1] != "Adventure" ||
!reflect.DeepEqual(args[2], []int{42}) || args[4] != 42 || args[5] != 21 {
t.Fatalf("unexpected query args: %#v", args)
}
allowedRatings, ok := args[3].([]string)
if !ok || !slices.Contains(allowedRatings, "PG") || slices.Contains(allowedRatings, "R") {
t.Fatalf("rating arg = %#v, want PG allowed and R blocked", args[3])
}
}
+286 -56
View File
@@ -5,92 +5,303 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
)
type FrameExtractOptions struct {
InputPath string
SeekSeconds float64
FFmpegPath string
HWAccel string
HWDevice string
ToneMap bool
RunFunc func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
InputPath string
SeekSeconds float64
FFmpegPath string
HWAccel string
HWDevice string
ToneMap bool
AllowSoftwareToneMap bool
RunFunc func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
softwareToneMapResolver *softwareToneMapFilterResolver
}
const (
hwAccelNone = "none"
hwAccelQSV = "qsv"
hwAccelVAAPI = "vaapi"
reasonChapterExtractFailed = "chapter_extract_failed"
reasonDecodeInvalidData = "decode_invalid_data"
reasonFFmpegProbeFailed = "ffmpeg_probe_failed"
reasonToneMapUnsupported = "tonemap_unsupported"
softwareToneMapProbeTimeout = 3 * time.Second
softwareToneMapFilterBT2390 = "zscale=t=linear:npl=100,format=gbrpf32le,tonemapx=tonemap=bt2390,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p"
softwareToneMapFilterHable = "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p"
)
type softwareToneMapProbeResult struct {
filter string
failureReason string
detail string
cacheable bool
}
type softwareToneMapProbeCall struct {
done chan struct{}
result softwareToneMapProbeResult
waiters int
}
type softwareToneMapFilterResolver struct {
mu sync.Mutex
byPath map[string]softwareToneMapProbeResult
inFlight map[string]*softwareToneMapProbeCall
probeFn func(ffmpegPath string) ([]byte, error)
}
var defaultSoftwareToneMapFilterResolver = newSoftwareToneMapFilterResolver(runFFmpegFilterProbe)
func newSoftwareToneMapFilterResolver(probeFn func(ffmpegPath string) ([]byte, error)) *softwareToneMapFilterResolver {
return &softwareToneMapFilterResolver{
byPath: make(map[string]softwareToneMapProbeResult),
inFlight: make(map[string]*softwareToneMapProbeCall),
probeFn: probeFn,
}
}
func (r *softwareToneMapFilterResolver) resolve(ffmpegPath string) (string, string, error) {
cacheKey := softwareToneMapCacheKey(ffmpegPath)
r.mu.Lock()
if result, ok := r.byPath[cacheKey]; ok {
r.mu.Unlock()
return softwareToneMapProbeResultValue(result)
}
if call, ok := r.inFlight[cacheKey]; ok {
call.waiters++
r.mu.Unlock()
<-call.done
return softwareToneMapProbeResultValue(call.result)
}
call := &softwareToneMapProbeCall{done: make(chan struct{})}
r.inFlight[cacheKey] = call
r.mu.Unlock()
result := probeSoftwareToneMapFilter(ffmpegPath, r.probeFn)
r.mu.Lock()
if result.cacheable {
r.byPath[cacheKey] = result
}
call.result = result
delete(r.inFlight, cacheKey)
close(call.done)
r.mu.Unlock()
return softwareToneMapProbeResultValue(result)
}
func softwareToneMapCacheKey(ffmpegPath string) string {
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
if absolutePath, err := filepath.Abs(ffmpegPath); err == nil {
return filepath.Clean(absolutePath)
}
return filepath.Clean(ffmpegPath)
}
return ffmpegPath
}
func softwareToneMapProbeResultValue(result softwareToneMapProbeResult) (string, string, error) {
if result.filter != "" {
return result.filter, "", nil
}
if result.failureReason == "" {
result.failureReason = reasonChapterExtractFailed
}
if result.detail == "" {
result.detail = "configured FFmpeg software HDR tone-map capability could not be determined"
}
return "", result.failureReason, errors.New(result.detail)
}
func probeSoftwareToneMapFilter(
ffmpegPath string,
probeFn func(ffmpegPath string) ([]byte, error),
) softwareToneMapProbeResult {
if probeFn == nil {
return softwareToneMapProbeResult{
failureReason: reasonFFmpegProbeFailed,
detail: "FFmpeg filter probe is unavailable",
}
}
output, err := probeFn(ffmpegPath)
if err != nil {
return softwareToneMapProbeResult{
failureReason: reasonFFmpegProbeFailed,
detail: "FFmpeg filter probe failed: " + playback.FormatFFmpegProbeFailure(err, output),
}
}
if !ffmpegFilterOutputHasToken(output, "zscale") {
return softwareToneMapProbeResult{
failureReason: reasonToneMapUnsupported,
detail: "configured FFmpeg lacks the required zscale filter",
cacheable: true,
}
}
if ffmpegFilterOutputHasToken(output, "tonemapx") {
return softwareToneMapProbeResult{filter: softwareToneMapFilterBT2390, cacheable: true}
}
if ffmpegFilterOutputHasToken(output, "tonemap") {
return softwareToneMapProbeResult{filter: softwareToneMapFilterHable, cacheable: true}
}
return softwareToneMapProbeResult{
failureReason: reasonToneMapUnsupported,
detail: "configured FFmpeg lacks the required tonemapx or tonemap filter",
cacheable: true,
}
}
func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string, error) {
ffmpegPath := opts.FFmpegPath
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
ffmpegPath := playback.ResolveFFmpegPath(opts.FFmpegPath)
runExtract := opts.RunFunc
if runExtract == nil {
runExtract = runFFmpegFrameExtract
}
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
resolvedDevice := opts.HWDevice
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
resolvedDevice = playback.PickRenderDevice("")
softwareToneMapResolver := opts.softwareToneMapResolver
if softwareToneMapResolver == nil {
softwareToneMapResolver = defaultSoftwareToneMapFilterResolver
}
cpuOpts := cpuFrameExtractOptions{
ctx: ctx,
inputPath: opts.InputPath,
seekSeconds: opts.SeekSeconds,
toneMap: opts.ToneMap,
runExtract: runExtract,
ffmpegPath: ffmpegPath,
softwareToneMapResolver: softwareToneMapResolver,
}
if resolvedAccel != "none" {
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
if supportsHardwareFrameExtract(resolvedAccel) {
// Resolve a multi-device hw_device list to one concrete GPU for this
// extraction; the reservation spans only the hardware attempt below.
resolvedDevice, releaseHWDevice := playback.AcquireHWDevice(opts.HWDevice, resolvedAccel)
defer releaseHWDevice()
if resolvedDevice == "" {
resolvedDevice = playback.PickRenderDevice("")
}
args, buildErr := buildFrameExtractArgs(opts.InputPath, opts.SeekSeconds, resolvedAccel, resolvedDevice, opts.ToneMap)
if buildErr != nil {
if opts.ToneMap {
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", buildErr)
}
} else {
if buildErr == nil {
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(true, opts.ToneMap))
data, err := runExtract(attemptCtx, ffmpegPath, args)
cancel()
releaseHWDevice()
if err == nil {
return data, "", nil
}
hwReason := classifyExtractError("hw", err)
if opts.ToneMap {
if hwReason == reasonDecodeInvalidData {
return nil, hwReason, wrapReason(hwReason, err)
}
cpuData, cpuReason, cpuErr := extractFrameCPU(
ctx,
opts.InputPath,
opts.SeekSeconds,
false,
runExtract,
ffmpegPath,
)
if cpuErr == nil {
return cpuData, "", nil
if opts.ToneMap && !opts.AllowSoftwareToneMap {
return nil, hwReason, wrapReason(hwReason, err)
}
return nil, cpuReason, fmt.Errorf("hardware extraction failed: %v; cpu fallback failed: %w", wrapReason(hwReason, err), cpuErr)
return extractFrameCPUFallback(
cpuOpts,
hwReason,
err,
)
}
releaseHWDevice()
if opts.ToneMap && !opts.AllowSoftwareToneMap {
return nil, reasonToneMapUnsupported, wrapReason(reasonToneMapUnsupported, buildErr)
}
return extractFrameCPUFallback(
cpuOpts,
reasonChapterExtractFailed,
buildErr,
)
}
if opts.ToneMap {
err := errors.New("hardware HDR tone mapping unavailable")
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", err)
if opts.ToneMap && !opts.AllowSoftwareToneMap {
err := errors.New("software HDR tone mapping is disabled")
return nil, reasonToneMapUnsupported, wrapReason(reasonToneMapUnsupported, err)
}
return extractFrameCPU(ctx, opts.InputPath, opts.SeekSeconds, opts.ToneMap, runExtract, ffmpegPath)
if resolvedAccel != hwAccelNone && !opts.ToneMap {
return extractFrameUnsupportedSDRWithRetry(cpuOpts)
}
return extractFrameCPU(cpuOpts)
}
func supportsHardwareFrameExtract(hwAccel string) bool {
return hwAccel == hwAccelQSV || hwAccel == hwAccelVAAPI
}
type cpuFrameExtractOptions struct {
ctx context.Context
inputPath string
seekSeconds float64
toneMap bool
runExtract func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
ffmpegPath string
softwareToneMapResolver *softwareToneMapFilterResolver
}
func extractFrameUnsupportedSDRWithRetry(opts cpuFrameExtractOptions) ([]byte, string, error) {
attemptCtx, cancel := context.WithTimeout(opts.ctx, extractTimeoutForAttempt(true, false))
data, err := opts.runExtract(
attemptCtx,
opts.ffmpegPath,
buildCPUFrameExtractArgs(opts.inputPath, opts.seekSeconds, ""),
)
cancel()
if err == nil {
return data, "", nil
}
hwReason := classifyExtractError("hw", err)
return extractFrameCPUFallback(opts, hwReason, err)
}
func extractFrameCPUFallback(
opts cpuFrameExtractOptions,
hwReason string,
hwErr error,
) ([]byte, string, error) {
cpuData, cpuReason, cpuErr := extractFrameCPU(opts)
if cpuErr == nil {
return cpuData, "", nil
}
return nil, cpuReason, fmt.Errorf(
"hardware extraction failed: %w; cpu fallback failed: %w",
wrapReason(hwReason, hwErr),
cpuErr,
)
}
func extractFrameCPU(
ctx context.Context,
inputPath string,
seekSeconds float64,
toneMap bool,
runExtract func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error),
ffmpegPath string,
opts cpuFrameExtractOptions,
) ([]byte, string, error) {
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(false, toneMap))
softwareToneMapFilter := ""
if opts.toneMap {
filter, reason, err := opts.softwareToneMapResolver.resolve(opts.ffmpegPath)
if err != nil {
return nil, reason, wrapReason(reason, err)
}
softwareToneMapFilter = filter
}
attemptCtx, cancel := context.WithTimeout(opts.ctx, extractTimeoutForAttempt(false, opts.toneMap))
defer cancel()
data, err := runExtract(attemptCtx, ffmpegPath, buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap))
data, err := opts.runExtract(
attemptCtx,
opts.ffmpegPath,
buildCPUFrameExtractArgs(opts.inputPath, opts.seekSeconds, softwareToneMapFilter),
)
if err != nil {
reason := classifyExtractError("cpu", err)
return nil, reason, wrapReason(reason, err)
@@ -106,12 +317,12 @@ func classifyExtractError(stage string, err error) string {
lower := strings.ToLower(message)
switch {
case strings.Contains(message, "No such filter") || strings.Contains(message, "tonemap") && strings.Contains(message, "Error"):
return "tonemap_unsupported"
return reasonToneMapUnsupported
case strings.Contains(lower, "invalid nal unit size"),
strings.Contains(lower, "error splitting the input into nal units"),
strings.Contains(lower, "invalid data found when processing input"),
strings.Contains(lower, "invalid as first byte of an ebml number"):
return "decode_invalid_data"
return reasonDecodeInvalidData
case stage == "hw" && strings.Contains(message, "signal: killed"):
return "hw_killed"
case stage == "hw" && isDeadlineError(err):
@@ -119,7 +330,7 @@ func classifyExtractError(stage string, err error) string {
case stage == "cpu" && isDeadlineError(err):
return "cpu_timeout"
default:
return "chapter_extract_failed"
return reasonChapterExtractFailed
}
}
@@ -143,15 +354,15 @@ func extractTimeoutForAttempt(hardware bool, hdr bool) time.Duration {
return cpuExtractTimeoutSDR
}
func buildCPUFrameExtractArgs(inputPath string, seekSeconds float64, toneMap bool) []string {
func buildCPUFrameExtractArgs(inputPath string, seekSeconds float64, softwareToneMapFilter string) []string {
args := []string{
"-hide_banner",
"-loglevel", "error",
"-ss", fmt.Sprintf("%.3f", seekSeconds),
"-i", inputPath,
}
if toneMap {
args = append(args, "-vf", "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=bt2390,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p")
if softwareToneMapFilter != "" {
args = append(args, "-vf", softwareToneMapFilter)
}
args = append(args,
"-frames:v", "1",
@@ -168,7 +379,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
"-loglevel", "error",
}
switch hwAccel {
case "qsv":
case hwAccelQSV:
if hwDevice == "" {
return nil, fmt.Errorf("qsv requires a render device")
}
@@ -179,7 +390,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
)
case "vaapi":
case hwAccelVAAPI:
if hwDevice == "" {
return nil, fmt.Errorf("vaapi requires a render device")
}
@@ -190,7 +401,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
"-hwaccel_output_format", "vaapi",
)
default:
return buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap), nil
return nil, fmt.Errorf("hardware chapter thumbnail extraction does not support %q", hwAccel)
}
filter := "hwdownload,format=nv12"
@@ -209,6 +420,25 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
return args, nil
}
func runFFmpegFilterProbe(ffmpegPath string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), softwareToneMapProbeTimeout)
defer cancel()
return exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").CombinedOutput()
}
func ffmpegFilterOutputHasToken(output []byte, token string) bool {
for _, line := range strings.Split(string(output), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 || !strings.Contains(fields[2], "->") {
continue
}
if strings.EqualFold(fields[1], token) {
return true
}
}
return false
}
func runFFmpegFrameExtract(ctx context.Context, ffmpegPath string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stdout, stderr bytes.Buffer

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