Compare commits

..
Author SHA1 Message Date
CoffeeKnyte d01b5de3bd fix(proxy): resolve edge client IP through the trusted-proxy boundary
The edge recorded the connecting peer's address (`RemoteAddr`) for every
monitoring record, ignoring forwarding headers. Behind an ingress, reverse
proxy or load balancer that is the *same address for every viewer*, so the
admin session list showed one indistinguishable client per node and any
per-viewer analysis built on those records was reading the ingress address.

The native API surface has resolved this correctly for a while via
`internal/clientip`, which walks `X-Forwarded-For` right-to-left and returns
the first untrusted hop. The edge simply never used it. This wires the same
resolver into proxy mode so both surfaces share one trust model.

The trust boundary is what makes this safe to believe: forwarding headers are
consulted only when the connecting peer is itself in
`clientip.trusted_proxies`, so an ordinary client cannot choose the address it
is recorded under. If the trust list cannot be loaded the resolver keeps an
empty list, which ignores forwarding headers entirely and falls back to the
peer — failing closed on trust rather than failing startup. A directly-exposed
edge with no resolver wired keeps the previous behavior.

The list is reloaded on the node config watcher's change hook, so the edge
follows a hot-reloaded setting without a restart, matching central.

Only proxy mode is affected; `internal/transcodenode` records no client
address.

This was found while scoping re-stream detection (now issue #522) — an
IP-based signal is meaningless if every viewer presents as the ingress — but
the defect is independent of that feature and is worth fixing on its own.

Part of #305
2026-07-31 01:33:06 +00:00
CoffeeKnyte 2bbe60219f docs(playback): correct the claim that restream fingerprints are ready to consume
Three documents asserted that the re-stream heuristic just needs a consumer
because `ClientIP` and friends "already flow through streammonitor". Planning
Batch 6 against the actual code showed that is false, and it is exactly the
class of overstatement this audit exists to correct.

`ClientIP` is one value per session, not a set:

- Integrated mode stamps it at session creation and no media request updates
  it — `internal/api/handlers/stream.go` never touches `ClientIP`.
- The edge tracker overwrites `records[sessionID]` on every `Track` /
  `EnsureEphemeral`, so concurrent pours leave only the last writer's address.
- `mergeStreams` then collapses multi-node records to a single winner and only
  backfills an address when the winner has none.

So several devices pulling one session present as a single address, and a
consumer polling the existing snapshot cannot see fan-out at all. Detecting it
requires bounded viewer observations collected at authenticated media-serve
time.

Also records two limits worth knowing before anyone scopes this again:

- An IP-based signal can only catch shared session-URL fan-out (C16). C14 —
  a downstream proxy re-broadcasting one pulled stream — is invisible by
  construction, because Silo sees exactly one address no matter how wide the
  fan-out. C14 stays scored as no defense.
- `internal/proxy/server.go`'s `edgeClientIP` reads `RemoteAddr` and ignores
  `X-Forwarded-For`, unlike the native surface which uses the trusted-proxy
  resolver in `internal/clientip`. Behind ingress every edge viewer collapses
  to the ingress address, so the signal is blind there until that is fixed.

No behavior change; documentation only.

Part of #305
2026-07-31 00:59:48 +00:00
CoffeeKnyte 5b72176142 docs(playback): score the liveness/replica batch and record its accepted limits
Rescores both matrices for A5, A6 and A7 and closes GAP-14.

Corrections to previously-recorded claims:

- The two claims flagged as "still false" are now true, with named bounds. The
  kill switch does keep a stream dead (revocation batch), and monitoring does
  never trust client progress (this batch) — with the one deliberate exception
  that a paused session holding an open, ping-checked realtime/WebSocket
  connection is exempt from reaping. That is an observed connection, not a
  reported position, and it preserves the issue #243 fix.
- GAP-14 listed four `Begin` call sites. There are five;
  `api/handlers/ebook_reader.go` was missing.
- Category A #7 is rescored honestly: A6 fixes cross-replica *monitoring*, so
  the excess is trimmed within ~120s. Synchronous admission is still
  per-process, so those starts are admitted and then trimmed rather than
  refused at the door. Recorded under "does not cover" rather than scored as a
  clean pass.
- Follow-up 0g's deferred cross-replica mirror race is closed: the Redis
  revocation mirror now merges server-side.
- 0f (A7) does not close E28 — there is still no server-wide connection cap,
  only a per-user transfer cap.

Adds an accepted-limitations section for the things a reader would otherwise be
surprised by: paused sessions aging from the last served byte, the bounded
never-served grace, `last_served_at` being omitted for never-served integrated
sessions, the advisory enforcer lease, the plain-SET fallback for the merge, and
that the Lua tests skip without SILO_TEST_REDIS_ADDR — which matters because
this repository runs no `go test` job in CI at all.

Records the Batch 6 re-stream decisions now that they are settled, including the
privacy posture: detection ships on by default and alert-only, retaining
per-viewer IP history on every deployment, with auto-kill behind an
operator setting that defaults to off.

Extends the AI-use disclosure with round 5, including the two review claims
rejected on verification and the defect each model found in the other's work.

Part of #305
2026-07-31 00:50:44 +00:00
CoffeeKnyte cd9e358bbc fix(playback): fail closed on transfer-registry saturation
`transfers.Registry` caps at 10,000 entries. Past that `Begin` returned
`ErrRegistryFull`, and every call site logged at Debug and served the file
anyway with its byte updates discarded. With no connection cap anywhere, one
actor could pin the registry and blind download-class monitoring for everyone —
which is the monitoring the abuse controls depend on.

Per decision A7, saturation now fails closed and is unreachable by a single
actor in the first place:

- A per-user concurrent-transfer cap (`playback.max_user_concurrent_transfers`,
  default 24) checked before the global limit, so one actor exhausts its own
  budget rather than the shared registry. 24 leaves headroom for
  multi-connection downloaders, which open 4-8 sockets per file and take a
  transfer id per concurrent Range request. `MaxPerUser` is a *pointer* option
  because a plain int cannot distinguish "unset, use the default" from
  "explicitly 0, unlimited".
- All five download-class call sites refuse rather than serve unmonitored:
  429 `transfer_limit_exceeded` for the per-user cap, 503
  `monitoring_unavailable` for a full registry, both with `Retry-After`.
  The handoff listed four sites; `internal/api/handlers/ebook_reader.go` was
  the missing fifth.
- The ABS file handler now admits the transfer *before* setting
  Content-Disposition and the audio Content-Type, so a rejection is not
  mislabeled as an audio attachment.

Two correctness fixes fall out of doing this properly:

- `End` now decrements the per-user count only when it actually removed an
  entry, and drops the map entry at zero so neither counts nor warning
  timestamps grow unbounded.
- The two download handlers registered `defer transfers.End(transfer.ID)`
  *before* the service called `Begin`, so a duplicate id would have removed
  another request's live record. `Begin` moves into the handler beside its
  `End`, and the service keeps its late DownloadID/MediaFileID enrichment
  through a new `Registry.Annotate`.

Saturation is logged at Warn inside the registry, rate-limited per user and
carrying the route and user id, rather than once per rejected request at each
call site. A7 asked for Warn at the call sites, but an actor parked at its cap
would then generate unbounded warning traffic — a log-amplification vector of
its own. No information is lost: route and user id are already on the Transfer.

429/503 are new failure statuses on existing endpoints, not repurposed ones.
silo-android and silo-apple need follow-up to retry with backoff on
`Retry-After`; the cap and the fail-closed behavior are advertised on
`GET /downloads/capability` so clients can feature-detect instead of probing.

The jellycompat call site is verified by reading only: that test package does
not compile on origin/main (pre-existing, unrelated).

Part 3 of 3 for the Batch 4 liveness/replica work.

Part of #305
2026-07-31 00:46:14 +00:00
CoffeeKnyte 40f117bd2d test(playback): assert the admin session view omits an unserved last_served_at
`TestHandleListSessionsAddsSiblingTransfersWithoutChangingSessionShape`
expected `last_served_at` to equal `Session.LastActivityAt`, encoding the
client-progress fallback that fc65dcaf removed. The admin session list is
built from the same `streammonitor.LiveLocalSessions` projection as the
enforcer, so a never-served session now omits the field instead of reporting
the client's last progress report as a serve time.

The field is omitted for want of a server-observed serve, not removed: the
test now also asserts it reappears once a transport actually serves.

Follow-up to fc65dcaf, which I verified without running ./internal/api/...

Part of #305
2026-07-31 00:44:43 +00:00
CoffeeKnyte 12f5d5f8ed fix(playback): share the live-stream picture across replicas
Every API replica ran its own enforcer over only its own SessionManager plus
the edge Redis records, because `nodesessions.NewTracker` is constructed only
in proxy/transcode mode — integrated streams never reached the shared
`silo:sessions:` namespace. Replicas were mutually blind, so under-enforcement
of the concurrent-stream cap was certain, and two replicas trimming from
different snapshots could over-kill.

Per decision A6 (Option A):

- `nodesessions.Publisher` mirrors the integrated process's live sessions into
  the shared keyspace every 10s. It re-SETs every live record on every tick so
  the 60s record TTL is renewed, and diffs only deletions so a stopped session
  leaves the shared picture at once rather than lingering for the TTL.
- The publisher's key namespace is derived from a process-unique instance id,
  not `resolveNodeIdentity()`. That helper returns SILO_NODE_NAME/NODE_NAME/
  hostname, so an operator setting it in shared env and scaling the deployment
  would give every replica the same namespace and they would delete each
  other's session records — worse than the blindness this fixes.
- `streamenforcer.Coordinator` elects a single evaluator per tick via a
  renewable Redis lease (a plain SET NX would lock the holder out of its own
  next tick). Each pass is bounded by the evaluation interval so it can never
  outlive the lease. No coordinator, or a Redis error, evaluates anyway:
  failing to coordinate must never mean failing to enforce.

Also closes the cross-replica gap Batch 3 recorded as an accepted limitation:
`mirrorToRedis` was an unconditional SET, so two replicas revoking the same key
could lose the stronger kill — and edges learn kills only from Redis. It now
merges server-side in Lua with the same two independent monotonic dimensions as
`applyLocal` (expiry never earlier, cutoff never backward, reason follows the
newer cutoff), sharing one Go definition of that comparison.

Two details that are easy to get wrong:

- The merge compares exact (unix_sec, nsec) pairs carried as additive fields on
  a dedicated mirror payload. RFC3339 strings cannot be compared
  lexicographically (Go omits trailing zeros, so "…:00Z" sorts after
  "…:00.5Z"), and millisecond truncation could retain an older cutoff and let a
  credential issued between two same-millisecond revocations keep streaming.
- The script merges before deciding to delete. Deleting on a lapsed *incoming*
  revocation, as the old unconditional path did, could remove a live permanent
  or longer-lived kill written by another replica.

The mirror payload is additive, so old and new nodes interoperate during a
rolling deploy, and any script failure falls back to the previous plain SET
with a once-only warning.

This fixes cross-replica *monitoring* only. Synchronous admission remains
per-process, so streams spread across replicas are still all admitted and are
trimmed asynchronously by the enforcer rather than refused at the door. A
distributed admission gate is separate work.

Part 2 of 3 for the Batch 4 liveness/replica work.

Part of #305
2026-07-31 00:22:23 +00:00
CoffeeKnyte fc65dcaf16 fix(playback): make session liveness server-observed
Client progress reports could keep a zero-byte phantom session alive and,
worse, make it outrank a genuinely-serving stream when the enforcer picked
over-cap victims.

`streammonitor.LiveLocalSessions` substituted `Session.LastActivityAt` for a
zero `LastServedAt`, and `LastActivityAt` is advanced by `UpdateProgress` and
by the realtime WebSocket hello/ack/result handlers. Since
`streamenforcer.selectVictims` keeps the `limit` most-recently-served streams,
a progress-only phantom sorted ahead of a real stream and the real one was
trimmed instead. Reaping had the same root cause: `sessionIsInactiveLocked`
keyed idleness on `LastActivityAt`, so a client that kept pinging held a
session open forever.

Per decision A5 (Option C), client progress is now UI metadata only and never
feeds enforcement or reaping:

- `LiveLocalSessions` projects `LastServedAt` verbatim, emitting an empty
  timestamp when the session has never served, so it sorts as the stalest
  over-cap victim.
- `sessionIsInactiveLocked` measures idleness from `LastServedAt`, falling back
  only to `StartedAt`.
- A configurable never-served window (`DefaultUnservedSessionGrace`, 2m, via
  `SetUnservedSessionGrace`) keeps a legitimately slow start from being reaped
  before its first byte, without granting a phantom unbounded life. It is a
  separate knob rather than a hardcoded floor so it cannot silently override
  `SetLivenessGracePeriods`.

In-flight transports remain exempt, so direct-play and remux long pours and
per-segment HLS serves are unaffected.

Paused sessions with an open realtime/WebSocket connection are exempt from
reaping. That preserves the issue #243 fix (reaping a paused transcode froze
clients) while staying within Option C: an open, ping-checked connection is
server-observed, unlike a client's reported progress, and the session still
consumes one of the user's cap slots.

Part 1 of 3 for the Batch 4 liveness/replica work.

Part of #305
2026-07-31 00:03:44 +00:00
CoffeeKnyte 4288c7c5c6 docs(playback): score the revocation batch and record its accepted limits
Marks #13, #7/M1, A2/#6, A1/#3 and A3/#5 resolved, and records the limits this
batch accepts rather than leaving them for the next reviewer to rediscover.

Retracts the last of the two false claims flagged in the serve-path docs pass:
the kill switch now does "keep a stream dead" for the token's reconstructable
life. The other -- monitoring does not "never trust client progress" -- is still
false and stays flagged until decision A5 lands.

Newly documented accepted limits:

- A user cutoff cannot cut an API-key-owned pour. API-key credentials carry no
  issue time, so they take the zero credential time and IsRevoked's documented
  fail-open contract applies. Deliberate, logged, and better than substituting
  time.Now(), which actively defeats the cutoff.
- Two central replicas revoking the same key can still race the Redis mirror,
  because mirrorToRedis is an unconditional SET rather than an atomic merge.
  Same-process writes are serialized by opMu; cross-replica convergence needs
  A6's shared picture.
- The over-cap TTL setting affects future revocations only. Monotonic expiry
  means it cannot shorten an existing kill; only an explicit unrevoke clears one,
  and the enforcer may recreate it while the over-count persists.
- Per-login logout cuts are still unavailable: cutting one device's live streams
  needs per-login identity in the stream credential, which is the
  authorization-generation model rather than the iat model chosen here.

Records that the enforcer's repeat kill is non-extending and why: with monotonic
expiry and a 30s evaluation loop, a plain long TTL would renew a wrong kill
indefinitely for as long as any stale record survived.

Part of #305.
2026-07-30 12:27:19 +00:00
CoffeeKnyte e5bf0155ad fix(playback): make revocation state converge and cutoffs credential-accurate
Five defects in revocation state and credential semantics. Lands after the
tracker-lifecycle batch on purpose: raising the over-cap TTL is only safe once
the count feeding it is trustworthy.

#13 -- a longer old revocation suppressed a newer cutoff. applyLocal kept or
replaced the WHOLE record by expiry, so when the existing revocation expired
later the new one was dropped entirely, including its newer RevokedAt. The
durable upsert did the same, with a comment documenting it as intentional.
RevokedAt is the user-kill CUTOFF, so this left a credential issued between the
two cutoffs valid -- a second admin kill after a user re-authenticates silently
failed to cut them. The two fields now merge independently: ExpiresAt stays
monotonic, RevokedAt advances to the later value, and reason follows the newer
cutoff. Both superseded comments are replaced rather than left contradicting the
code. Session-kind revocation still ignores RevokedAt, so the enforcer's
re-revoke cannot weaken a session kill.

Also fixed while here: Redis received the merged record but pub/sub published the
raw input one, so under pub/sub-only delivery (Redis down) an edge got the newer
short record without the older long expiry and lost monotonicity. Both now carry
the merged record.

#7 + M1 -- Postgres could indefinitely block the urgent Redis kill.
RevokeWithWarnings held the global opMu across all propagation, stripped the
caller's deadline with WithoutCancel, and did the durable Postgres upsert BEFORE
Redis, on a pool with no statement timeout. The local kill still applied, so
playback on that process was fine -- but edge propagation, pub/sub, the admin
response and every later revoke/unrevoke stalled behind the lock. Redis and
pub/sub now go first, and the detached context is bounded. WithoutCancel is kept
deliberately: propagation must outlive an aborted admin request.

opMu scope is deliberately NOT narrowed. mirrorToRedis is an unconditional SET
with no atomic merge, so same-process serialization is what stops an older value
overwriting a newer one; narrowing the lock would also let an unrevoke interleave
with a revoke's propagation. Bounding the context caps how long the lock can be
held, which is the actual reported harm. The remaining cross-replica race -- two
central replicas racing the same SET -- is documented, not half-fixed; it needs
A6's shared picture.

A2 / #6 -- a missed unrevoke got resurrected. In-memory tombstones already
existed, but being process-local they did not survive a restart or reach a
replica that missed the pub/sub event, so maintain's durable self-heal
re-Upserted the surviving entry and the ban returned. Tombstones are now durable,
via two nullable columns on stream_revocations rather than a second table: a
tombstone is a state of the same key, and it needs its own expiry horizon
separate from the revocation's. The upsert rejects a stale replica's write while
a tombstone is live but lets a genuinely newer revocation clear it, and warm
paths apply tombstones BEFORE revocations so an un-banned key cannot be restored
as a live kill. Tombstones are pruned on the same sweep, so the table cannot grow
without bound.

A1 / #3 -- over-cap kills reopened after 5 minutes while the token stayed
reconstructable for 24h. The TTL now derives from playback.MaxTokenTTL rather
than duplicating 24h, behind a validated setting.

Critically, the enforcer uses a revoke-if-absent path rather than re-revoking.
Expiry is monotonic and the enforcer re-evaluates every 30s, so a plain long TTL
would slide expiry forward by another full lifetime on every pass -- making a
wrong kill effectively permanent for as long as any stale record persisted, with
only an explicit unrevoke to recover it. Admin Revoke keeps its monotonic
behaviour; only the enforcer's own repeat kill is non-extending. The setting is
documented as affecting future revocations only, since monotonic expiry means it
cannot shorten one already issued.

A3 / #5 -- the user cutoff compared against a fresh time.Now() taken at request
entry, so a request from a pre-cutoff login could look post-cutoff and escape the
kill. The credential time is now the access token's iat.

Two deliberate choices worth stating. API-key credentials carry no issue time, so
they pass the zero time and, per IsRevoked's documented contract, are never
matched by a user cutoff: a user kill provably cannot cut an API-key-owned pour.
That is an accepted, logged, documented hole -- and strictly better than
time.Now(), which actively defeats the cutoff. And jellycompat uses the compat
session's CreatedAt rather than the bridged Silo token's iat, because that token
refreshes without a new Jellyfin login, so its iat would advance on refresh and
let a refreshed credential slip past a cutoff.

Stream tokens are now bound to their route: a token whose SessionID does not match
the URL's session_id is rejected with 403 instead of being silently ignored,
matching the reconstruction helper that already refused a different session.

Per-login logout cuts remain out of scope -- they need per-login identity in the
stream credential. S5 (the (sessionID, userID, startedAt) clump) is rejected as
ceremony now that A3 is the iat option rather than the generation model.

#12 (closing an RSS feed does not cut its current pour) is deferred: it needs a
namespaced revocation id that cannot collide with real session ids, that id
threaded onto public feed requests, and protection against a new feed inheriting
an old tombstone.

Part of #305.
2026-07-30 12:27:05 +00:00
CoffeeKnyte b1502d70f5 docs(playback): score the tracker-lifecycle batch and correct the async claim
Marks GAP-15 resolved and records the three wrong-over-cap-count defects the
tracker-lifecycle batch closed, with why they had to precede the revocation
batch: decision A1 removes the self-healing that limits the damage of a miscount.

Notes why the v3 identity split had not yet caused visible harm -- fresh v3
starts sent no owner attribution at all, so the transport record landed under
user 0, which the enforcer skips, silently exempting the stream from the cap
rather than double-counting it. That is a worse failure than the double count it
masked, and worth recording so the next reader does not "fix" only the visible
half.

Corrects the "fully async monitoring" claim instead of rushing the queue: the
first Redis projection write per session is synchronous so the record is visible
before the request returns, and later liveness and byte updates ride the refresh
tick. The consequence -- a slow Redis adds latency to the first request of a
stream -- is now stated. The ordered, lifecycle-aware projection queue is
deliberately deferred until its startup, drain, cleanup ordering, backpressure
and refresh interaction can be designed together; a naive fire-and-forget
projection is exactly what caused the ghost-session defect this batch fixed.

Follow-up list renumbered accordingly; the GAP-14 (A7) and opMu items are
retained, not dropped.

Part of #305.
2026-07-30 09:10:22 +00:00
CoffeeKnyte ecb4555eec fix(playback): make edge tracking lifecycle-safe and session identity canonical
Five defects that all produced a WRONG over-cap count, which is why they land
before the revocation batch: decision A1 raises the over-cap revocation TTL from
5m to ~24h, removing the self-healing that currently limits the damage of a
miscount. A false positive after A1 blocks a legitimate stream for a day, so the
count has to be trustworthy first.

#1 -- overlapping edge requests deleted a live stream. Tracker.sessions was a
set and Remove tore down all state plus the Redis key, while both proxy pour
handlers deferred removal unconditionally. Two overlapping Range GETs on one
session id -- ordinary seek behaviour -- meant the first to finish deleted the
record while the second was still pouring, and later AddBytes calls were then
dropped because AddBytes ignores bytes for a session with no live record. The
stream went invisible to authoritative monitoring while still serving.

Track now returns a Lease that the request-scoped caller releases exactly once;
teardown happens when the last live lease is released. A plain refcount would
have been wrong: Track(A) -> Remove -> Track(B) -> Release(A) decrements B, and
clamping at zero does not help because the count legitimately belongs to B. That
is not hypothetical -- the transcode node deliberately replaces sessions under
the same id so a quality switch does not orphan ffmpeg, and it calls
unconditional Remove from its reaper and stop paths. So each generation carries
an epoch, Remove and Cleanup bump it, and a release from a superseded generation
is a logged no-op. Lease identity is a set rather than a counter, which makes a
duplicate release detectable instead of silently destructive.

The transcode node keeps using Remove: its Track calls are not request-scoped and
are correctly owned by session lifecycle. "Every Track needs a paired Release" is
true only of the request-scoped callers.

#8 -- async transcode tracking could leave a permanent ghost. The tracking write
ran as a bare goroutine with a WithoutCancel context, so if stop won the race the
delayed Track recreated the record after cleanup -- and because it landed in
sessions, Snapshot treated it as live until Remove and it NEVER idle-expired. A
permanent phantom inflating its owner's count, able to trigger false over-cap
kills of that user's real streams. The write now takes the per-session lifecycle
lock that stop and reap already hold, and re-checks session pointer identity
before writing, so a stopped or replaced generation cannot resurrect a record.
Pointer identity rather than id equality is what makes same-id replacement safe.
The write stays off the request path -- the API server and the playback client
are blocked on the 202.

#9 + M3 -- protocol-v3 counted one stream twice. The stream token carries a
transport id distinct from the logical session id, and the node tracked under the
transport id while the API/proxy record used the logical one, so mergeStreams saw
two streams. M3 was the reason this had not yet bitten: the v3 fresh-start caller
sent no owner attribution at all, so the transport record landed under user 0,
which the enforcer skips -- silently exempting the stream from the cap entirely.
Fresh v3 starts now carry the logical session id and full owner attribution
(both were already in scope at the call site), and merging is keyed on logical
identity where present via one shared helper used by both merge functions, which
had already drifted apart once.

The enforcer view resolves SessionID to the logical id so a kill targets the real
session rather than a replaceable transport generation. The raw admin view keeps
the transport id and exposes logical_session_id as an additive omitempty field,
advertised on the node-sessions capability endpoint, so the v1 response shape is
unchanged.

GAP-15 -- edge transcode liveness was request-observed. touchTranscodeSession
fired before proxying, so hammering dead segment URLs advanced LastServedAt with
zero bytes served. Visibility and liveness are now separate operations:
EnsureEphemeral makes a session visible without claiming bytes were served, and
served-byte liveness advances only from a 2xx/206 upstream response. Previously
the proxy metered every upstream body regardless of status, so a node 404's error
body counted as served bytes -- moving the touch later would not have fixed it.

S4 -- LiveLocalSessions moved from the HTTP handlers package to streammonitor,
which owns monitoring. A background enforcer importing api/handlers was
backwards. Pure move; its existing mapping assertions moved with it. The
LastActivityAt fallback inside it is left as-is -- decision A5 removes it in the
liveness batch.

Verified with go test -race across nodesessions, proxy and transcodenode; the
overlap regression test was confirmed to fail under the old unconditional
teardown.

Part of #305.
2026-07-30 09:09:35 +00:00
CoffeeKnyte 8a9cf04a41 docs(playback): score the serve-path fixes and record the settled decisions
The two coverage matrices and the plan's as-built deltas described GAP-10..GAP-15
as open and asserted things the code did not do. Rescored against the code as it
now stands, and made the remaining overclaims explicit rather than leaving them
to be discovered by the next reviewer.

Marked resolved with the reasoning that produced each fix: GAP-10 (ABS Unwrap),
GAP-11 (ebook observability), GAP-12 (rolling-deadline cut latch, pulled forward
from the revocation batch because it makes GAP-10's fix inert), GAP-13
(BytesServed merged as a max). GAP-14 and GAP-15 stay open with their batch and,
for GAP-14, decision A7 attached.

Two claims are called out as STILL FALSE wherever they appear, so the blanket
phrasing does not creep back: the kill switch does not "keep a stream dead" (an
over-cap kill reopens after 5m until A1 lands) and monitoring does not "never
trust client progress" (the LastActivityAt fallback survives until A5 lands).

Documents a bound the plan never stated: the in-flight watcher polls, so a cut
lands within one interval -- 5s in production -- not instantly. Every "cut" claim
in these docs now says so.

Corrects two pieces of stale guidance that would have misled the next
implementer. Follow-up 0b said to reuse guardRevocationCut for the ebook routes:
that wrapper keys on a session_id URL param and an ?st= token those routes do not
carry, so it compiles and silently guards nothing. Follow-up 0c suggested a
sticky flag on RollingDeadlineWriter: unreachable as written, because the rolling
writer is constructed inside the serve helpers and wraps the writer the watcher
holds, so Unwrap() -- which walks toward the socket -- can never reach it.

Records decisions A1-A8 in one table so the follow-up issues inherit them, plus
one new finding for the A3 batch: streamRequestIdentity verifies a stream token's
signature but never checks its SessionID against the URL's session_id.

Expands the AI-use disclosure to the standard docs/ai-contributions.md requires:
tool, exact model IDs, involvement classification, and the adversarial findings
and resolution from both directions of the cross-model review -- including the
five defects found in the AI implementation and the one found in the AI review of
it. Also states plainly that pnpm is absent on the host used for this round, so
frontend evidence must come from CI, and that the jellycompat test package does
not compile on main, so the compat changes here are verified by reading only.

Part of #305.
2026-07-30 08:37:22 +00:00
CoffeeKnyte 70064823a6 feat(api): advertise stream monitoring and revocation capabilities
The admin kill-list endpoints and the transfers field on the live node-sessions
payload shipped with no capability advertisement, contrary to the repo's
additive-v1 rule that new features expose capability endpoints for feature
detection rather than relying on version sniffing. (S3)

Adds two endpoints, each mounted beside the surface it describes so an
advertisement cannot outlive the route it advertises:

  GET /admin/node-sessions/capabilities
  GET /admin/streams/revocations/capabilities

The node-sessions capability keeps schema support and runtime availability as
separate booleans. The transfers key is always present in the response shape
once that endpoint exists, but the process-local registry behind it is optional
wiring -- an edge deployment can legitimately serve it as an empty list forever.
Collapsing the two into one flag would advertise download monitoring that is not
actually running, so `transfers` reports the schema and `transfers_active`
reports the wiring.

The revocation capability advertises the closed {kind} vocabulary accepted by
DELETE /admin/streams/revocations/{kind}/{id}. To make that advertisement
impossible to desync, the accepted kinds move into a single map that both the
wire parser and the capability handler read -- previously the parser duplicated
the list in a switch, so a newly-accepted kind could go unadvertised and clients
would feature-detect an incomplete vocabulary. The kinds are returned sorted,
because map iteration order is randomised and this is a wire response that must
be stable across calls.

Tests cover the drift in both directions over the whole vocabulary rather than
sampling rejected strings, the sort stability, and that the handler returns a
copy so a caller cannot corrupt the package-level vocabulary.

Note the previous plan for this work put both capabilities on
/admin/sessions/capabilities. That was wrong: that endpoint documents the
Postgres-backed /admin/sessions payload, whereas transfers belongs to
/admin/node-sessions, which is gated on NodeRepo and may not be mounted at all.

No frontend change: web/src does not consume either endpoint.

Part of #305.
2026-07-30 08:31:48 +00:00
CoffeeKnyte 7adedf6429 fix(playback): close the serve-path monitoring and kill-switch gaps
Six defects on byte-serving paths, all of which made the PR's monitoring and
kill-switch claims narrower than documented.

#2/GAP-10 -- the ABS in-flight kill switch was a production no-op. accessLog
wraps every ABS route, and its statusRecorder implemented Write, WriteHeader,
Hijack and Flush but not Unwrap, so http.NewResponseController dead-ended and
SetWriteDeadline returned ErrNotSupported. A multi-GB audiobook pour survived a
RevokeUser. The existing test passed throughout because it called handlers
directly and never saw the middleware; the new test drives the mounted router
over a real socket, and both new assertions fail if Unwrap is removed again.

GAP-11 -- ebook, comic and PDF serving was invisible and un-killable: no meter,
no transfer record, no Refuse, no watcher, on a route that serves cbz/cbr/pdf
files routinely 100 MB-1 GB+. It now follows the ABS file-handler idiom. Note
guardRevocationCut is deliberately *not* reused here: it keys on a session_id
URL param and an ?st= token this route does not carry, so it would have
compiled and silently guarded nothing. Cap-exempt per decision A4 -- admission
is untouched and neither route consumes a stream slot.

#10 -- the no-proxy remote transcode hop forwarded segments through a bare
RollingDeadlineWriter, so bytes on the API hop went unaccounted for a supported
topology. Metering is scoped to media bodies; manifests are excluded so a
rewritten playlist is not counted as media, and a mid-copy failure is no longer
silently discarded.

#16 -- native, proxy and compat subtitle pours were entry-gated only. They now
carry a transport span, a meter and an in-flight watcher. Proxy subtitle bytes
are attributed only when a tracker record already exists: taking Track/Remove
lifecycle ownership per subtitle request would walk straight into the
overlapping-request defect (#1) that Batch 2 addresses. Compat subtitle
extraction is buffered and rejects bitmap formats, so a cut stops delivery but
not extraction already in progress.

M2 -- the proxy deferred tracker Remove with the request context, which is
already canceled on client disconnect, so the Redis DEL never happened and the
key lingered until TTL -- a false over-cap window that could get a legitimate
stream killed. Cleanup now uses a short bounded context.

GAP-13 -- mergeStreams took the freshest record wholesale and never merged
BytesServed, so a stream that poured 8 GiB at an edge could report 0. Merged as
a max, not a sum: the records are two observers of one pour. Fixed in
DedupeSessionInfos too, which had the same hole and feeds the admin view.

#11 needed no behaviour change -- that route was already metered, registered and
watched, and commit c24d8396 plus this Unwrap fix are what make its cut work.
Its comment claimed download-class exemption while the comment above it said the
?token= form is for iOS streaming; both facts and decision A4 are now stated.

Part of #305.
2026-07-30 06:11:43 +00:00
CoffeeKnyte c24d839681 fix(playback): latch the revocation cut against rolling write deadlines
WatchAndCut set the socket write deadline to now once and returned. On every
pour wrapped in httpstream.RollingDeadlineWriter, bump() pushes that deadline
back out to now+180s before the next write once the 15s bumpStep has elapsed,
and the constructor bumps immediately. Nothing re-armed the watcher, so a
revocation cut was reliable against a stalled pour and unreliable against a
fast-draining one -- weakest against exactly the ripping case it exists to stop.
(GAP-12)

The obvious fix does not work: the rolling writer is constructed *inside*
ServeDirectPlay/ServeRemux and wraps the writer the watcher holds, so it sits
*above* the watcher. Unwrap() walks toward the socket, so the watcher can never
reach it by writer introspection. The cut therefore has to travel by a side
channel.

Adds httpstream.CutLatch, carried on the request context, which RollingDeadline-
Writer consults in bump(). Once latched, the writer never extends the deadline
again -- a cut is a deliberate hang-up, not a stall. bump() re-checks the latch
after setting a future deadline so a concurrent cut cannot be lost to the
check/set race, and WatchAndCut now keeps re-applying the deadline on each tick
instead of returning after the first cut, as belt-and-braces for any writer
topology the latch does not reach.

A failed SetWriteDeadline is now logged instead of silently discarded, so the
next wrapper that breaks the Unwrap chain is loud rather than invisible. It is
logged once per watcher, since the re-applying tick would otherwise repeat it
every 5s for the life of the pour.

WatchAndCutContext and NewRollingDeadlineWriterCtx are added alongside the
existing signatures rather than replacing them, so this commit changes no
caller behaviour on its own. Options.WatchInterval makes the 5s poll injectable
for real-socket tests; the production default is unchanged.

Note the polling bound this leaves: a revoked pour keeps delivering for up to
one watch interval (5s in production) before the cut lands.

Part of #305.
2026-07-30 06:10:33 +00:00
CoffeeKnyte 09de4f08ec feat(downloads): monitor in-flight download pours in memory
Six routes poured full media with no monitor record and no byte measurement:
the two native download routes, the Jellyfin-compat download, both ABS file
variants, and the public ABS RSS feed file. They were the last invisible bytes
on the server.

- internal/transfers: a process-local, in-memory registry of active pours.
  Bounded (10k entries) with rate-limited "full" warnings, all request-derived
  strings normalized and length-clamped (ABS and jellycompat carry no bounded
  client name, so those fields are header-derived and untrusted), overflow-safe
  byte accumulation, deterministic snapshots, nil-safe throughout. No I/O on any
  path, and no persistence: a pour dies with the process, so durable rows would
  only need reaping after a crash.
- Reuses the existing playback.SessionMeteredWriter via ServedBytesRecorder
  rather than adding a second writer — that writer is where the sendfile
  (ReadFrom) and kill-cut (Unwrap) hazards live and both have regressed before.
- Deliberately NOT plumbed through streammonitor/streamenforcer, which are
  untouched. Downloads stay off the live-stream path by construction: there is
  no type, field or collection through which one can reach the enforcer, so a
  download can never be counted against max_streams or trimmed as an over-cap
  stream.
- Admin visibility is a sibling `transfers` array on the existing
  /admin/nodes/sessions response; the `sessions` array is byte-for-byte
  unchanged. Transfers appear only in the unfiltered listing, since a node_id
  filter targets an edge and these are process-local.
- Per-pour ids are unique, never the download id: concurrent and repeated
  GET/Range requests against one download row are legitimate and would collide.
  The id is minted in the handler (where the revocation watcher is armed) and
  the registry entry is opened in the service only after file/artifact
  resolution succeeds, so a failed auth or lookup never registers a transfer.
- Defer order is an invariant at every call site: End is registered before the
  meter's Close so LIFO flushes the tail first. Reversed, a final sub-1MiB flush
  lands on an unknown id and is silently lost. Pinned by a test.

No schema change and no migration: downloads.bytes_sent keeps its documented
meaning (a lifecycle marker set to file_size on completion, not a live counter)
and is untouched.

Phase 2 — killing a single download, and a standing per-user download block —
is deliberately deferred. A user revocation already cuts in-flight download
pours; it is a cutoff, so it does not refuse new ones. The unique per-pour id
exists so phase 2 only changes "" to that id at each WatchAndCut site.

Part of the stream monitoring & kill-switch epic.
2026-07-29 16:42:37 +00:00
CoffeeKnyte 561e00e4ca docs(playback): record the post-plan hardening pass in the plan's as-built deltas
The plan doc records post-plan changes in its As-built deltas section; the
follow-up audit's four findings (integrated byte accounting, the VERIFY-4
transcode grace, the operator kill-list API with unrevoke, and the previously
unguarded Audiobookshelf byte routes) belong there so the intent doc and the
as-built matrix agree. Also restates what stays deferred.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:22:34 +00:00
CoffeeKnyte f3e1fd3b38 docs(playback): refresh the monitoring and abuse coverage matrices
Both matrices are the stated review checklist for this area, so a stale claim
in them is a real defect.

- stream-abuse-matrix: "correction #1", row A8 and follow-up #1 claimed the
  async enforcer reads the raw users.max_streams column and is "dormant on a
  default install". That was fixed earlier on this branch by resolving
  SessionManager.EffectiveLimits (the same group-merged policy synchronous
  admission uses); all three now describe the shipped behaviour. Added rows for
  the ABS authenticated file route, the ABS public track and the public RSS
  feed file route.
- playback-paths matrix: added ABS to the serving/monitoring/kill tables, marked
  VERIFY-4 resolved with the served-at-driven transcode grace, recorded
  integrated BytesServed and the deliberately distinct server-observed
  LastServedAt, and documented the kill-list endpoints, Unrevoke, the tombstone
  and the publish-failure fail-safe.
- Restated as explicitly deferred rather than implied: multi-replica
  enforcement (VERIFY-3) and the transcode-node ghost-record sweep. Both need
  the monitoring write side restructured and deserve their own change.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:17:46 +00:00
CoffeeKnyte e35efddac7 fix(audiobooks): enforce the stream kill switch on ABS byte-serving routes
The Audiobookshelf-compat surface sits outside the monitoring and kill design
entirely — neither architecture matrix mentioned it. Three routes pour full
media and none consulted the kill switch:

- /(abs/)api/items/{id}/file/{ino} and /download — bearer auth, no revocation
  check, no in-flight cut.
- /(abs/)public/session/{sid}/track/{idx} — mounted outside bearerAuth (the
  session id is the capability); it held a transport marker but was unkillable.
- /feed/{slug}/file/{ino} — no auth at all, the slug is the capability;
  invisible and unkillable, and closing a feed only blocked the next request
  rather than cutting a pour already in flight.

Each surface now passes its real credential-issue time, because a user
revocation is a cutoff, not a ban: it matches only streams whose credential
predates it. ABS bearer tokens are stateless JWTs that OnUserSessionsRevoked
does not delete, so passing request-entry time (as the jellycompat login path
safely does) would have meant a user kill never refused a later ABS request.

- bearerAuth carries the JWT's iat into ctxAuth; the authenticated file route
  uses it.
- The public track uses the persisted playback session's StartedAt and passes
  the native session id so session-level kills land too, plus the shared
  metered writer for byte accounting.
- The feed file uses the feed's CreatedAt for an owner cutoff — a feed opened
  before the revocation dies, one opened after re-authenticating serves — and
  arms the in-flight cut.

The authenticated file route stays download-class: like the native and
jellycompat download routes it is exempt from the live-stream cap and from
streammonitor, and is covered by no download quota. It is now killable and
explicitly documented rather than quietly invisible. Bringing all three
download-class routes under one quota and one monitor record is tracked as a
follow-up rather than adding a fourth per-route model here.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:17:36 +00:00
CoffeeKnyte fca9f38a33 feat(api): operator-visible stream kill list with unrevoke
The kill switch had no operator surface. streamrevoke.Store.List() existed and
was called from nowhere, so an admin could only terminate one session by id or
revoke a user's streams as a side effect of editing their account — with no way
to see what was revoked, choose a TTL, or undo a mistake. Because expiry is
deliberately monotonic, a wrong 24h kill was irreversible.

- GET/POST/DELETE /api/v1/admin/streams/revocations, admin-only, additive.
- Explicit wire-to-internal kind mapping: the wire accepts "session" (and
  "sess"), the store key is "sess". Passing the wire string straight into a Key
  would create a revocation IsRevoked never consults.
- Validation: non-empty bounded session ids; canonical positive user ids
  (strconv.Itoa round-trip, so "01" is rejected — the cache key is the
  canonical form); ttl_seconds bounded to 30d so the duration cannot overflow;
  bounded reason and request body. DELETE of an absent key is idempotent.
- Store.Unrevoke, guarded by a bounded in-memory tombstone: the tombstone is
  installed and the local entry dropped BEFORE the slow durable/Redis deletes,
  so a concurrent poll reconcile cannot re-apply the row it just read. A newer
  Revoke on the same key clears the tombstone, so an unrevoke never suppresses
  a later legitimate kill. Tombstones age out with the kill they replaced.
- maintain() takes the same operation lock as Revoke/Unrevoke around its
  durable block, closing the window where a poll tick could re-Upsert a row
  Unrevoke had just deleted — invisible until a restart resurrected the kill.
- Durable self-heal now compares expiry, not mere presence, so a failed Revoke
  mirror leaves a stale shorter row that the next tick repairs.
- A failed unrevoke publish fails safe: other processes keep the kill until it
  expires. Propagation failures surface as warnings rather than weakening the
  local result.

Unrevoking an over-cap victim is legal but the async enforcer will re-revoke it
on its next pass while the user is still over cap; that is documented at the
endpoint.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:17:15 +00:00
CoffeeKnyte 4b2031fdf8 feat(playback): account served bytes and hold transcode liveness server-side
Integrated deployments recorded no served bytes at all: BytesServed was only
ever advanced by the edge writer, so a single-node install showed
bytes_served: 0 for every stream. Worse, integrated LastServedAt was mapped
from Session.LastActivityAt, which UpdateProgress advances — letting a client
influence which of its own streams the over-cap enforcer trims first.

- internal/playback: Session gains BytesServed and a distinct LastServedAt.
  LastServedAt advances only from server-observed events (AddServedBytes,
  BeginTransport, EndTransport) and never from a client progress report.
  LastActivityAt keeps its exact prior meaning and reaping semantics.
- internal/playback/metered_writer: one shared SessionMeteredWriter for every
  integrated pour. It forwards io.ReaderFrom so the kernel sendfile path
  survives, guards the fallback copy against re-entering ReadFrom, and
  implements Unwrap() so the revocation cut's SetWriteDeadline still reaches
  the socket. Both properties have regressed on this branch before (GAP-3,
  GAP-9) and are now pinned by a chain test.
- Wired at every integrated pour: native direct-play/remux and transcode
  segment, jellycompat direct/remux and HLS segment. Each site defers the tail
  flush; the wrapper alone loses the final partial chunk.
- Close VERIFY-4 (buffer-ahead evasion): an unpaused transcode gets a bounded
  10m grace measured from the server-observed clock. This covers local,
  cleanly-completed and offloaded transcodes uniformly, unlike an ffmpeg
  liveness probe, which sees only local processes and reports false once a
  copy-mode encode finishes ahead of playback. Paused sessions keep their
  load-bearing 30m grace.
- internal/nodesessions: the same idle window one layer out — transcode
  records idle out at 180s instead of 60s, applied through a single helper
  used by ActiveCount, Snapshot and refreshAll so the node's count and status
  view cannot disagree.

Part of the stream monitoring & kill-switch epic.
2026-07-29 15:16:59 +00:00
CoffeeKnyte 6ae1ea30c8 docs: adversarial abuse matrix + enforcement architecture decision
- docs/architecture/stream-abuse-matrix.md: red-team of this branch against
  ~29 abuse stories, each scored on detection vs enforcement, including the
  three places the branch's own docs oversold the code.
- docs/superpowers/plans/2026-07-07-abuse-cold-enforcer-architecture.md:
  the deny-list vs lease analysis and phased plan, with the final decision
  note: keep the single revocation pipeline with reason-scoped TTLs; the
  two-tier lease is dropped.

Part of #306.
2026-07-29 12:28:06 +00:00
CoffeeKnyte 0cc706e066 fix(playback): resolve group-merged cap in async stream enforcer
The enforcer's LimitFunc read the raw users.max_streams column, which is 0
("inherit from group") for every standard account since migrations
20260702180000/20260702190000 moved the real cap into the Default Group.
The enforcer treats limit <= 0 as unlimited, so the async over-cap brain
never trimmed anyone on a default install — only per-process synchronous
admission held, leaving the cross-node backstop it was built for a no-op.

Extract admission's limit lookup into a shared SessionLimitProvider
(GetByID + access.EffectivePolicyForUser) and feed the enforcer through
it, so admission and the enforcer can never disagree about a user's
effective cap again.

Part of #306.
2026-07-29 12:28:06 +00:00
CoffeeKnyte 53c9313b57 docs(playback): monitoring & kill-switch plan + as-built coverage matrix
Capture the design intent and the shipped coverage for the stream monitoring +
revocation kill-switch work, refreshed to match the final implementation and the
monitoring → kill-switch → docs commit layout.

- docs/superpowers/plans/2026-07-04-stream-monitoring-and-kill-switch.md: the
  implementation plan of record (detection / enforcement / async brain split),
  with a Status banner and as-built deltas noting what shipped differently
  (durable Postgres mirror wired non-optional, shared Refuse/WatchAndCut
  helpers, monotonic expiry, ownership carry-forward, first-class monitoring
  fields).
- docs/architecture/playback-paths-monitoring-kill-matrix.md: the as-built
  coverage matrix across server layout × playback type × route, GAP-1..GAP-9
  resolution notes, restart-durability axis, and open verification items
  (VERIFY-3/4, operator config keys, compat download quota).

Part of the stream monitoring & kill-switch epic.
2026-07-29 12:26:03 +00:00
CoffeeKnyte 0217cce2df feat(playback): stream kill switch + async over-cap enforcer
Add the enforcement layer on top of server-observed monitoring: a revocation
kill switch that stops any stream within ~120s and keeps it dead, plus an async
over-cap enforcer that drives kills off the live monitoring picture — entirely
off the per-segment hot path and with no client-protocol change.

- internal/streamrevoke: the central kill list. IsRevoked is a pure in-memory
  lookup safe on the request hot path; a Redis pub/sub + poll mirror keeps edge
  caches current, and a Postgres durable mirror lets kills survive a server
  restart AND a Redis flush so a restart-resilient stream cannot be reconstructed
  and re-served after being killed. A user revocation is a cutoff (kills tokens
  minted before it, spares post-reauth tokens), not a 24h ban.
- internal/streamenforcer: async over-cap brain — reads the monitoring snapshot
  and per-user limits, selects victims, and collapses every reason (exceeded
  limit, admin terminate, abuse) to the same action: write a revocation.
- Edge + native + jellycompat enforcement: proxy refuses revoked sessions on
  every request and cuts long direct-play/remux pours mid-stream; the transcode
  node guards both serve and the reconstruct path so a killed session is never
  re-spawned after a node restart; jellycompat serve surfaces close their
  kill-switch coverage holes.
- streamtoken.IssuedTime exposes the token iat the user-kill cutoff compares
  against; token IssuedTime + revocation guards wire through router, downloads,
  and admin terminate-by-id (with admin-list dedupe).
- Restore sendfile zero-copy on direct-play/remux byte counting so the monitor's
  served-byte accounting does not cost the sendfile fast path.
- migrations/sql: stream_revocations durable table.

Part of the stream monitoring & kill-switch epic.
2026-07-29 12:26:03 +00:00
CoffeeKnyte 22ffaad911 feat(playback): server-observed stream monitoring (async, no client trust)
Introduce a first-class, authoritative view of what is actually streaming,
observed server-side and never trusting client progress reports. This is the
base observation layer the kill switch and async over-cap enforcer build on.

- internal/streammonitor: live-stream snapshot model plus pluggable Sources
  (local func source, Redis source, multi-source fan-in) so a single node and a
  multi-node deployment expose the same picture.
- internal/nodesessions/tracker: serve-activity attribution — LastServedAt and
  served-byte counters advance from real serving, not client pings, giving an
  authoritative liveness signal.
- Client identity as monitoring attribution: Origin ("native" | "jellycompat")
  and ClientName ride the server-signed stream token (streamtoken.Claims) and
  the transcode-start request so an edge/transcode node — which never sees the
  originating API path — can stamp them onto its live-session record. These are
  attribution only: not byte-affecting and not a trust assertion.
- Serve-activity marks on the transcode node (MarkServed) so a node's own record
  reflects real serving instead of a LastServedAt frozen at start time.
- Admin observation surfaces: node/session listing carries owner + client
  identity and dedupes multi-record sessions.

Part of the stream monitoring & kill-switch epic.
2026-07-29 12:23:48 +00:00
736 changed files with 27918 additions and 102517 deletions
+4 -16
View File
@@ -2,11 +2,9 @@
# 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 example for Intel/AMD VA-API or Intel Quick Sync:
# COMPOSE_FILE=docker-compose.yml:docker-compose.vaapi.yml
# Linux example for NVIDIA NVENC:
# Linux/macOS example for enabling NVIDIA override:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows NVIDIA example:
# Windows example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
@@ -29,15 +27,6 @@ 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.
@@ -51,8 +40,7 @@ POSTGRES_SHM_SIZE=8gb
# POSTGRES_TUNE_DB_SIZE=auto
# POSTGRES_TUNE_CONNECTIONS=100
# 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.
# Optional host port overrides for docker-compose.yml.
# PORT=8090
# Used only after Jellyfin-compatible app support is enabled by an admin.
# JF_PORT=8096
@@ -89,7 +77,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 custom worker deployments.
# Optional standalone worker identity settings for docker-compose.remote-transcode.yml.
# NODE_NAME=transcode-01
# NODE_URL=http://transcode-01.example.com:8082
-185
View File
@@ -1,185 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GOPROXY: https://proxy.golang.org,direct
GOPRIVATE: github.com/Silo-Server/*
GONOSUMDB: github.com/Silo-Server/*
# Pinned so a lint gate cannot change its mind between two runs of the same
# commit. Built from source below rather than downloaded: a released binary
# refuses to run against a Go version newer than the one it was built with,
# and go.mod tracks Go closely enough that this repo is regularly ahead.
GOLANGCI_LINT_VERSION: v2.12.2
# The default token is read-write. Nothing here needs to write, and a token
# that cannot push is one fewer thing a compromised dependency can reach.
permissions:
contents: read
jobs:
go:
name: Go
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# golangci-lint needs the merge base to tell this branch's lines from
# the ones it inherited.
fetch-depth: 0
persist-credentials: false
# github.com/h2non/bimg binds libvips through cgo and pkg-config, so
# nothing under ./... compiles without the headers. The Dockerfile
# installs the same package in its build stage.
- name: Install libvips
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libvips-dev
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
# cmd/silo embeds the built frontend, so nothing under ./... compiles
# without web/dist. The Go jobs never serve it, so a placeholder is
# enough; the Docker workflow builds the real bundle.
- name: Stub the embedded frontend bundle
run: make embed-stub
- name: Build
run: go build ./...
- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "::error::gofmt is required on:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
# Scoped to the lines this branch touched. The repo does not pass a full
# golangci-lint run today — there are a few hundred pre-existing findings,
# which is why the Go half of `make lint` has never been enforced — and
# blocking every PR on a cleanup nobody has done would just get the gate
# removed again. New and changed lines have to be clean, so the count only
# falls from here.
- name: Install golangci-lint
run: go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}"
- name: Lint changed lines
env:
# A PR carries its target branch; a push to main compares against
# main's own history, which leaves the merge base at HEAD and lints
# nothing new. Read through the environment rather than interpolated
# into the script.
BASE_REF: ${{ github.base_ref || github.event.repository.default_branch }}
run: |
git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
golangci-lint run --new-from-merge-base="origin/${BASE_REF}" ./...
# A manifest change that does not regenerate leaves every client reading
# stale keys, which the contract exists to prevent.
- 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
# defaults. Without this job those tests exist but never run.
- name: Test
run: make test-go
web:
name: Web
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
# The pnpm version comes from web/package.json's packageManager field —
# there is no package.json at the repo root, and `defaults.run` does not
# apply to an action's own inputs.
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
- name: Set up Node
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Format check
run: pnpm run format:check
- name: Typecheck and build
run: pnpm run build
# Includes the appearance-cache ownership tests, which are the regression
# guard for cross-account leaks in the localStorage warm start.
- name: Test
working-directory: .
run: make test-web
# The generated web binding is compared after prettier, so this half of
# the bindings check lives here rather than in the Go job, which has no
# pnpm. It needs Go to run the generator.
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Verify the generated web settings binding is current
working-directory: .
run: make verify-settings-bindings-web
docs:
name: Docs hygiene
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Verify no local paths leaked into committed docs
run: make verify-local-paths
-2
View File
@@ -81,5 +81,3 @@ docker-compose.override.yml
docker-compose.local.yml
.playwright-cli/
output/
/settingsgen
/playbackfixtures
+14 -19
View File
@@ -59,24 +59,19 @@ linters:
misspell:
locale: US
exclusions:
# Anchored regexes, not directory names: `paths` matches anywhere in the
# path, so a bare `web` also excluded internal/jellycompat/web_component.go,
# internal/webhooksync/, internal/notifications/webhook*.go and every other
# non-test file with "web" in its name — 14 files that were being linted
# before.
paths:
- ^web/
- ^migrations/
issues:
exclude-dirs:
- web
- migrations
rules:
# Allow repeated strings in test files
- path: _test\.go
linters:
- goconst
exclude-rules:
# Allow repeated strings in test files
- path: _test\.go
linters:
- goconst
# Allow unchecked errors in test cleanup/defer
- path: _test\.go
text: "Error return value is not checked"
linters:
- errcheck
# Allow unchecked errors in test cleanup/defer
- path: _test\.go
text: "Error return value is not checked"
linters:
- errcheck
+2 -19
View File
@@ -71,30 +71,18 @@ SDK, in the catalog, or in a specific plugin repo.
## Building and verifying
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make test`, `make migrate-status`
/ `make migrate-up` — read the `Makefile` for the rest. Local services:
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make migrate-status` /
`make migrate-up` — read the `Makefile` for the rest. Local services:
`docker compose up -d postgres redis`.
`make test-go` runs the whole Go suite. A Go test that cannot pass yet carries a `t.Skip` and the
reason in its own source, not an entry in a Makefile variable. `make test-web` still skips the
files in `WEBTEST_KNOWN_FAILURES`, which predate the CI gate; that list may only shrink — delete an
entry together with its fix, and never add to it to make a new change pass.
Before opening a merge request:
```bash
make lint
make test
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
```
`.github/workflows/ci.yml` runs these on every pull request, with one difference worth knowing:
`make lint` runs `golangci-lint` over the whole tree, while CI runs it with `--new-from-merge-base`
so only the lines a branch touched have to be clean. The repo does not pass a full run today, so
expect local output to include findings that are not yours and that CI will not fail on. Do not add
to them.
Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`.
## Skills
@@ -118,11 +106,6 @@ Additive-only within `/api/v1`:
- New features expose capability endpoints for feature detection rather than relying on version
sniffing. Contract strategy and tooling: issue #135.
Treat this as binding. The one exception: `/api/v1` is not locked yet, so a removal taken before
lock is in scope — but only when it is recorded in the pre-lock removals table in
[docs/architecture/v1-scope.md](docs/architecture/v1-scope.md) and ships before the lock. Assume
any removal not listed there is a mistake.
## Pull requests
Conventional Commit subjects (`feat(playback): add realtime session hub`). One concern per PR.
+7 -16
View File
@@ -6,10 +6,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge requ
## Prerequisites
- **Go** 1.26.4+
- **Node.js** 22+ with **pnpm** 10.32.1
- **PostgreSQL** 18 with pgvector
- **Redis**
- **Go** 1.24+
- **Bun** 1.0+
- **PostgreSQL** 18+
- **FFmpeg** (for transcoding support)
## Local Development
@@ -17,13 +16,6 @@ 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
@@ -34,8 +26,7 @@ make dev-frontend
make dev-backend
```
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.
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`).
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.
@@ -88,7 +79,7 @@ database URL should be read from a non-default env file.
go test ./...
# Frontend tests
cd web && pnpm test
cd web && bun test
```
## Linting
@@ -98,8 +89,8 @@ cd web && pnpm test
golangci-lint run
# Frontend
cd web && pnpm run lint
cd web && pnpm run format:check
cd web && bun run lint
cd web && bun run format:check
```
## Project Structure
-4
View File
@@ -32,10 +32,6 @@ COPY --from=frontend_dist / web/dist
COPY cmd/ cmd/
COPY internal/ internal/
COPY migrations/ migrations/
# The settings contract is a Go package (contracts/settings/v1) that embeds the
# manifest, so the binary carries the exact bytes it was built from. It lives
# outside internal/ because clients vendor these files.
COPY contracts/ contracts/
ARG BUILD_REVISION
ARG BUILD_DIRTY=false
RUN --mount=type=cache,target=/root/.cache/go-build \
-3
View File
@@ -29,9 +29,6 @@ COPY --from=frontend /app/web/dist web/dist
COPY cmd/ cmd/
COPY internal/ internal/
COPY migrations/ migrations/
# See Dockerfile: the settings contract is an embedded Go package outside
# internal/, so the build fails without it.
COPY contracts/ contracts/
# Stage 3: Build Go binary for dev using a local plugin SDK checkout passed via
# BuildKit named context `silo_plugin_sdk`.
+1 -138
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 playback-fixtures verify-playback-fixtures
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
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)/..))
@@ -54,126 +54,6 @@ lint:
golangci-lint run
cd web && pnpm run lint
# Frontend test files that fail on main today. This list is shrink-only: delete
# an entry along with its fix, and never extend it to land a change. The Go
# suite has no equivalent — a Go test that cannot pass yet carries a t.Skip and
# its reason in the source, where whoever reads the test finds it.
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/setup-wizard/steps/ServerStorageStep.test.tsx \
--exclude src/player/hooks/useASSSubtitles.test.tsx
# The Go binary embeds the built frontend, so every Go build and test needs
# web/dist to exist. Tests never serve it, so a placeholder is enough; `make
# build` still builds the real bundle.
embed-stub:
@mkdir -p web/dist
@[ -e web/dist/index.html ] || printf '<!doctype html>\n' > web/dist/index.html
# Run the Go and frontend test suites.
test: test-go test-web
test-go: embed-stub
go test ./...
test-web:
cd web && pnpm exec vitest run $(WEBTEST_KNOWN_FAILURES)
# Regenerate the settings-contract bindings for every language.
#
# The client repos are siblings of this one (see CLAUDE.md); a missing checkout
# is skipped rather than failing, so a server-only developer can still run this.
#
# The conformance fixture (contracts/settings/v1/conformance.json) travels with
# the bindings: the vendored copy in web/src/lib is what the web runner reads.
# The Kotlin and Swift copies land together with their runners in the client
# repos, which will pick their own test-resource paths.
SILO_ANDROID_DIR ?= $(abspath ../silo-android)
SILO_APPLE_DIR ?= $(abspath ../silo-apple)
settings-bindings:
@mkdir -p internal/settingskeys
go run ./cmd/settingsgen -lang go -out internal/settingskeys/keys.go
gofmt -w internal/settingskeys/keys.go
go run ./cmd/settingsgen -lang ts -out web/src/lib/settingsContract.ts
@cd web && pnpm exec prettier --write src/lib/settingsContract.ts >/dev/null
cp contracts/settings/v1/conformance.json web/src/lib/settingsConformance.json
@if [ -d "$(SILO_ANDROID_DIR)" ]; then \
go run ./cmd/settingsgen -lang kotlin \
-out "$(SILO_ANDROID_DIR)/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt"; \
echo "wrote Kotlin bindings to $(SILO_ANDROID_DIR)"; \
else \
echo "skipping Kotlin: $(SILO_ANDROID_DIR) not checked out"; \
fi
@if [ -d "$(SILO_APPLE_DIR)" ]; then \
go run ./cmd/settingsgen -lang swift \
-out "$(SILO_APPLE_DIR)/iosApp/iosApp/Networking/SettingKeys.generated.swift"; \
echo "wrote Swift bindings to $(SILO_APPLE_DIR)"; \
else \
echo "skipping Swift: $(SILO_APPLE_DIR) not checked out"; \
fi
# Fail when the committed bindings disagree with the manifest, so a manifest
# change cannot merge without regenerating what every client reads.
#
# Split in two because the generated TypeScript is compared after prettier, and
# only the Web CI job has pnpm: the Go job runs this target, the Web job runs
# verify-settings-bindings-web. Locally, `verify-settings-bindings-all` is both.
verify-settings-bindings:
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
go run ./cmd/settingsgen -lang go | gofmt > "$$CHECK_DIR/keys.go" && \
diff -u internal/settingskeys/keys.go "$$CHECK_DIR/keys.go" \
|| { echo "::error::internal/settingskeys/keys.go is stale; run make settings-bindings"; exit 1; }
@diff -u web/src/lib/settingsConformance.json contracts/settings/v1/conformance.json \
|| { echo "::error::web/src/lib/settingsConformance.json is stale; run make settings-bindings"; exit 1; }
@echo "settings bindings are current"
# The half that needs pnpm: regenerate the web binding, format it the way the
# bindings target does, and compare. Without this a manifest change could merge
# with a stale settingsContract.ts, which is what every web control renders from.
verify-settings-bindings-web:
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
go run ./cmd/settingsgen -lang ts -out "$$CHECK_DIR/settingsContract.ts" && \
cd web && pnpm exec prettier --log-level silent --config .prettierrc \
--write "$$CHECK_DIR/settingsContract.ts" && cd .. && \
diff -u web/src/lib/settingsContract.ts "$$CHECK_DIR/settingsContract.ts" \
|| { echo "::error::web/src/lib/settingsContract.ts is stale; run make settings-bindings"; exit 1; }
@echo "web settings binding is current"
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
@@ -191,23 +71,6 @@ migrate-validate:
migrate-status:
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-status
# Roll back every migration newer than VERSION (the version to KEEP).
#
# Not a routine operation: it discards data. It exists because some migrations
# are Go rather than SQL — the settings backfill and the jellycompat
# DisplayPreferences move — and those are registered in-process, so the goose
# CLI above cannot see or reverse them.
#
# This is a RANGE, not a list: everything newer than VERSION comes off, including
# migrations belonging to other features that happen to sort in between. Check
# `make migrate-status` and read the down of each one you are about to revert.
# Take a backup first regardless; the per-user SQLite stores have no down path.
#
# Usage: make migrate-down-to VERSION=<timestamp from migrate-status>
migrate-down-to:
@if [ -z "$(VERSION)" ]; then echo "usage: make migrate-down-to VERSION=<timestamp from make migrate-status>"; exit 1; fi
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-down-to "$(VERSION)"
# Apply pending Goose migrations through Silo's bootstrapping runner.
migrate-up:
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-only
+26 -65
View File
@@ -15,22 +15,14 @@ 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 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.
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.
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:
@@ -51,21 +43,6 @@ application for a one-command start.
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.
@@ -101,39 +78,32 @@ 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 Search Profile
### Optional Profiles
PostgreSQL full-text search works without any optional services. Meilisearch is available when you
want its search provider:
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.
| Profile | Command | Description |
|---|---|---|
| default | `docker compose up -d` | Integrated server plus bundled PostgreSQL and Redis |
| `search` | `docker compose --profile search up -d` | Add the optional Meilisearch service |
| `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 |
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.
You can enable both optional examples together:
### Distributed Examples
```sh
docker compose --profile proxy --profile transcode up -d
```
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.
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.
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.
### 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.
### Deployment Notes
@@ -146,10 +116,7 @@ helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/co
## Configuration
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.
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.
### Server Modes
@@ -216,26 +183,24 @@ move fully to a custom `postgresql.conf`.
If you prefer running Silo without Docker:
1. **Install prerequisites**: Go 1.26.4+, Node.js 22+, pnpm 10.32.1, PostgreSQL 18 with pgvector, Redis, and FFmpeg.
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
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)
2. **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
@@ -247,10 +212,6 @@ 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
@@ -1,858 +0,0 @@
// 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},
})
}
-628
View File
@@ -1,628 +0,0 @@
// Command settingsgen emits typed bindings for the settings contract.
//
// One generator for every language rather than one per repo: the whole point of
// the contract is that four codebases agree on keys, types, scopes and
// defaults, and four independently-written generators would be four chances to
// disagree. Each client repo vendors the manifest and runs this to regenerate.
//
// Usage:
//
// settingsgen -lang go -out internal/settingskeys/keys.go
// settingsgen -lang ts -out web/src/lib/settingsContract.ts
// settingsgen -lang kotlin -out <path> -package org.siloserver.silo.model.settings
// settingsgen -lang swift -out <path>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
func main() {
lang := flag.String("lang", "", "go, ts, kotlin or swift")
out := flag.String("out", "", "file to write (default stdout)")
pkg := flag.String("package", "", "package or namespace for the generated code")
flag.Parse()
contract, err := settingscontract.Load()
if err != nil {
fail("loading contract: %v", err)
}
var body []byte
switch *lang {
case "go":
body, err = generateGo(contract, defaultString(*pkg, "settingskeys"))
case "ts":
body, err = generateTypeScript(contract)
case "kotlin":
body, err = generateKotlin(contract,
defaultString(*pkg, "org.siloserver.silo.model.settings"))
case "swift":
body, err = generateSwift(contract)
default:
fail("unknown -lang %q: want go, ts, kotlin or swift", *lang)
}
if err != nil {
fail("generating %s: %v", *lang, err)
}
if *out == "" {
_, _ = os.Stdout.Write(body)
return
}
if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source
fail("writing %s: %v", *out, err)
}
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...)
os.Exit(1)
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// remoteAndLocal returns every definition, sorted by key so the output is
// stable: a generator whose output depends on manifest authoring order would
// produce spurious diffs on every unrelated manifest edit.
func sortedDefinitions(contract *settingscontract.Manifest) []*settingscontract.Definition {
defs := make([]*settingscontract.Definition, 0, len(contract.Definitions))
for i := range contract.Definitions {
defs = append(defs, &contract.Definitions[i])
}
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
return defs
}
// identifierFor turns a dotted key into a language identifier:
// playback.subtitle_language becomes PlaybackSubtitleLanguage.
func identifierFor(key string) string {
var out strings.Builder
for _, part := range strings.FieldsFunc(key, func(r rune) bool {
return r == '.' || r == '_' || r == '-'
}) {
out.WriteString(strings.ToUpper(part[:1]))
out.WriteString(part[1:])
}
return out.String()
}
// screamingCase turns a dotted key into PLAYBACK_SUBTITLE_LANGUAGE.
func screamingCase(key string) string {
replaced := strings.NewReplacer(".", "_", "-", "_").Replace(key)
return strings.ToUpper(replaced)
}
const generatedHeader = `Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT.
Regenerate with: make settings-bindings
Every key, type, scope and default here comes from the manifest, so a client
cannot drift from the server's contract by editing a constant. Adding a setting
is a manifest change plus a regeneration, never a hand-written key.`
func generateGo(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
fmt.Fprintf(&out, "// Revision is the manifest revision these bindings were generated from.\nconst Revision = %d\n\n",
contract.Revision)
out.WriteString("// Setting keys, one constant per definition.\nconst (\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, "\t// %s\n", def.Label)
fmt.Fprintf(&out, "\t%s = %q\n", identifierFor(def.Key), def.Key)
}
out.WriteString(")\n\n")
out.WriteString("// Remote lists every key the server stores.\nvar Remote = []string{\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
}
}
out.WriteString("}\n\n")
out.WriteString("// ClientLocal lists keys the contract defines but the server never stores.\n")
out.WriteString("var ClientLocal = []string{\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
}
}
out.WriteString("}\n")
return out.Bytes(), nil
}
func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
var out bytes.Buffer
out.WriteString("/**\n")
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight(" * "+line, " ") + "\n")
}
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")
out.WriteString(" value: string;\n")
out.WriteString(" introducedIn: number;\n")
out.WriteString("}\n\n")
out.WriteString("export interface SettingOptionSet {\n")
out.WriteString(" type: string;\n")
out.WriteString(" options: readonly SettingSuggestedOption[];\n")
out.WriteString("}\n\n")
out.WriteString("export const SETTING_OPTION_SETS = {\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %s: {\n", name)
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
out.WriteString(" options: [\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " { value: %q, introducedIn: %d },\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ],\n")
out.WriteString(" },\n")
}
out.WriteString("} as const satisfies Record<string, SettingOptionSet>;\n\n")
out.WriteString("export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;\n\n")
out.WriteString("export const SETTING_KEYS = {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /** %s */\n", def.Label)
fmt.Fprintf(&out, " %s: %q,\n", screamingCase(def.Key), def.Key)
}
out.WriteString("} as const;\n\n")
out.WriteString("export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];\n\n")
// The full definition table, so the UI can render controls from the
// contract rather than a hand-kept parallel manifest.
out.WriteString("export interface SettingDefinition {\n")
out.WriteString(" key: SettingKey;\n")
out.WriteString(" type: string;\n")
out.WriteString(" nullable: boolean;\n")
out.WriteString(" persistence: \"remote\" | \"client_local\";\n")
out.WriteString(" /** The manifest revision this definition first appeared in. A client\n")
out.WriteString(" * pinned to a newer contract than the server's advertised revision must\n")
out.WriteString(" * hide definitions, scopes, enum members and widened bounds introduced\n")
out.WriteString(" * after that revision — the server would reject them. */\n")
out.WriteString(" introducedIn: number;\n")
out.WriteString(" scopes: readonly string[];\n")
out.WriteString(" /** Revision each scope became writable at, aligned with scopes. */\n")
out.WriteString(" scopeIntroducedIn: readonly number[];\n")
out.WriteString(" resolutionOrder: readonly string[];\n")
out.WriteString(" defaultValue: unknown;\n")
out.WriteString(" label: string;\n")
out.WriteString(" description: string;\n")
out.WriteString(" category: string;\n")
out.WriteString(" control?: string;\n")
out.WriteString(" unit?: string;\n")
out.WriteString(" /** Advisory: platforms this setting is expected to matter on. Absent\n")
out.WriteString(" * means everywhere. A client hides settings for platforms it is not,\n")
out.WriteString(" * rather than disabling them without explanation. */\n")
out.WriteString(" platforms?: readonly string[];\n")
out.WriteString(" suggestedOptions?: SettingOptionSetId;\n")
out.WriteString(" unsetLabel?: string;\n")
out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number }[];\n")
out.WriteString(" /** Present on enums whose members are ranked, so a ceiling or floor has a direction. */\n")
out.WriteString(" ordered?: boolean;\n")
out.WriteString(" minimum?: number;\n")
out.WriteString(" maximum?: number;\n")
out.WriteString(" /** Bound history, oldest first, when a bound was widened after revision 1;\n")
out.WriteString(" * a client filtering to an older server revision applies the newest entry\n")
out.WriteString(" * whose introducedIn does not exceed it. */\n")
out.WriteString(" minimumHistory?: readonly { value: number; introducedIn: number }[];\n")
out.WriteString(" maximumHistory?: readonly { value: number; introducedIn: number }[];\n")
out.WriteString(" step?: number;\n")
out.WriteString(" /** The policy input that narrows this setting, when the manifest binds one. */\n")
out.WriteString(" constrainedBy?: {\n")
out.WriteString(" policyInput: string;\n")
out.WriteString(" constraint: \"ceiling\" | \"floor\" | \"allowlist\" | \"locked\";\n")
out.WriteString(" };\n")
out.WriteString("}\n\n")
out.WriteString("export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " %q: {\n", def.Key)
fmt.Fprintf(&out, " key: %q,\n", def.Key)
fmt.Fprintf(&out, " type: %q,\n", def.ValueSchema.Type)
fmt.Fprintf(&out, " nullable: %t,\n", def.ValueSchema.Nullable)
fmt.Fprintf(&out, " persistence: %q,\n", def.Persistence)
fmt.Fprintf(&out, " introducedIn: %d,\n", def.IntroducedIn)
fmt.Fprintf(&out, " scopes: [%s],\n", quotedScopes(def))
fmt.Fprintf(&out, " scopeIntroducedIn: [%s],\n", scopeRevisions(def))
fmt.Fprintf(&out, " resolutionOrder: [%s],\n", quotedResolution(def))
fmt.Fprintf(&out, " defaultValue: %s,\n", defaultLiteral(def))
fmt.Fprintf(&out, " label: %s,\n", jsString(def.Label))
fmt.Fprintf(&out, " description: %s,\n", jsString(def.Description))
fmt.Fprintf(&out, " category: %q,\n", def.Category)
if def.Control != "" {
fmt.Fprintf(&out, " control: %q,\n", def.Control)
}
if def.Unit != "" {
fmt.Fprintf(&out, " unit: %q,\n", def.Unit)
}
if len(def.Platforms) > 0 {
fmt.Fprintf(&out, " platforms: [%s],\n", quotedStrings(def.Platforms))
}
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel: %s,\n", jsString(def.UnsetLabel))
}
if len(def.ValueSchema.Values) > 0 {
out.WriteString(" values: [\n")
for _, member := range def.ValueSchema.Values {
encoded, err := json.Marshal(member.Value)
if err != nil {
return nil, err
}
fmt.Fprintf(&out, " { value: %s, label: %s, introducedIn: %d },\n",
encoded, jsString(member.Label), memberRevision(def, member))
}
out.WriteString(" ],\n")
}
if def.ValueSchema.Ordered {
out.WriteString(" ordered: true,\n")
}
if minimum, ok := def.ValueSchema.Minimum.Current(); ok {
fmt.Fprintf(&out, " minimum: %s,\n", trimFloat(minimum))
if history := boundHistory(def, def.ValueSchema.Minimum); history != "" {
fmt.Fprintf(&out, " minimumHistory: [%s],\n", history)
}
}
if maximum, ok := def.ValueSchema.Maximum.Current(); ok {
fmt.Fprintf(&out, " maximum: %s,\n", trimFloat(maximum))
if history := boundHistory(def, def.ValueSchema.Maximum); history != "" {
fmt.Fprintf(&out, " maximumHistory: [%s],\n", history)
}
}
if def.ValueSchema.Step != nil {
fmt.Fprintf(&out, " step: %s,\n", trimFloat(*def.ValueSchema.Step))
}
if def.ConstrainedBy != nil {
fmt.Fprintf(&out, " constrainedBy: { policyInput: %q, constraint: %q },\n",
def.ConstrainedBy.PolicyInput, def.ConstrainedBy.Constraint)
}
out.WriteString(" },\n")
}
out.WriteString("};\n")
return out.Bytes(), nil
}
func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
out.WriteString("data class SettingSuggestedOption(\n")
out.WriteString(" val value: String,\n")
out.WriteString(" val introducedIn: Int,\n")
out.WriteString(")\n\n")
out.WriteString("data class SettingOptionSet(\n")
out.WriteString(" val type: String,\n")
out.WriteString(" val options: List<SettingSuggestedOption>,\n")
out.WriteString(")\n\n")
out.WriteString("data class SettingPresentation(\n")
out.WriteString(" val suggestedOptions: String? = null,\n")
out.WriteString(" val unsetLabel: String? = null,\n")
out.WriteString(")\n\n")
out.WriteString("object SettingKeys {\n")
fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision)
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /** %s */\n", def.Label)
fmt.Fprintf(&out, " const val %s = %q\n", screamingCase(def.Key), def.Key)
}
// The allowlist Android maintained by hand, generated instead. The whole
// class of "wrote a local key to the server" bug is a manifest question now.
out.WriteString("\n /** Every key the server stores. Safe to flush. */\n")
out.WriteString(" val REMOTE: List<String> = listOf(\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
out.WriteString(" )\n\n")
out.WriteString(" /** Contract-known keys that never leave the device. */\n")
out.WriteString(" val CLIENT_LOCAL: List<String> = listOf(\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
out.WriteString(" )\n")
// Type classification, which Android kept as a second hand-maintained table
// that had to agree with the first.
for _, group := range []struct {
name string
types []settingscontract.ValueType
}{
{"BOOLEAN_KEYS", []settingscontract.ValueType{settingscontract.TypeBoolean}},
{"INT_KEYS", []settingscontract.ValueType{settingscontract.TypeInteger}},
{"DOUBLE_KEYS", []settingscontract.ValueType{settingscontract.TypeNumber}},
} {
// Remote only: these tables drive how a value read back from the
// server is parsed, and a client_local key never comes back from the
// server at all. Listing one would describe a wire format that has no
// wire.
fmt.Fprintf(&out, "\n val %s: Set<String> = setOf(\n", group.name)
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
continue
}
for _, want := range group.types {
if def.ValueSchema.Type == want {
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
}
}
}
out.WriteString(" )\n")
}
out.WriteString("}\n\n")
out.WriteString("object SettingPresentationMetadata {\n")
out.WriteString(" val OPTION_SETS: Map<String, SettingOptionSet> = mapOf(\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %q to SettingOptionSet(\n", name)
fmt.Fprintf(&out, " type = %q,\n", optionSet.Type)
out.WriteString(" options = listOf(\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " SettingSuggestedOption(%q, %d),\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ),\n")
out.WriteString(" ),\n")
}
out.WriteString(" )\n\n")
out.WriteString(" val DEFINITIONS: Map<String, SettingPresentation> = mapOf(\n")
for _, def := range sortedDefinitions(contract) {
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
continue
}
fmt.Fprintf(&out, " SettingKeys.%s to SettingPresentation(\n", screamingCase(def.Key))
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions = %q,\n", def.SuggestedOptions)
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel = %q,\n", def.UnsetLabel)
}
out.WriteString(" ),\n")
}
out.WriteString(" )\n\n")
out.WriteString(" fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List<String> {\n")
out.WriteString(" val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList()\n")
out.WriteString(" return OPTION_SETS[setId]?.options\n")
out.WriteString(" ?.filter { it.introducedIn <= revision }\n")
out.WriteString(" ?.map { it.value }\n")
out.WriteString(" .orEmpty()\n")
out.WriteString(" }\n")
out.WriteString("}\n")
return out.Bytes(), nil
}
func generateSwift(contract *settingscontract.Manifest) ([]byte, error) {
var out bytes.Buffer
for _, line := range strings.Split(generatedHeader, "\n") {
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
}
out.WriteString("\nimport Foundation\n\n")
out.WriteString("public struct SettingSuggestedOption: Hashable, Sendable {\n")
out.WriteString(" public let value: String\n")
out.WriteString(" public let introducedIn: Int\n")
out.WriteString("}\n\n")
out.WriteString("public struct SettingOptionSet: Hashable, Sendable {\n")
out.WriteString(" public let type: String\n")
out.WriteString(" public let options: [SettingSuggestedOption]\n")
out.WriteString("}\n\n")
out.WriteString("public struct SettingPresentation: Hashable, Sendable {\n")
out.WriteString(" public let suggestedOptions: String?\n")
out.WriteString(" public let unsetLabel: String?\n")
out.WriteString("}\n\n")
out.WriteString("/// Every setting the contract defines.\n")
out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n")
for _, def := range sortedDefinitions(contract) {
fmt.Fprintf(&out, " /// %s\n", def.Label)
fmt.Fprintf(&out, " case %s = %q\n", lowerFirst(identifierFor(def.Key)), def.Key)
}
out.WriteString("}\n\n")
out.WriteString("public extension SettingKey {\n")
fmt.Fprintf(&out, " static let revision = %d\n\n", contract.Revision)
out.WriteString(" /// Keys the server stores. The rest never leave the device.\n")
out.WriteString(" static let remote: [SettingKey] = [\n")
for _, def := range sortedDefinitions(contract) {
if def.IsRemote() {
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
}
}
out.WriteString(" ]\n\n")
out.WriteString(" static let clientLocal: [SettingKey] = [\n")
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
}
}
out.WriteString(" ]\n")
out.WriteString("}\n\n")
out.WriteString("public enum SettingPresentationMetadata {\n")
out.WriteString(" public static let optionSets: [String: SettingOptionSet] = [\n")
for _, name := range sortedOptionSetNames(contract) {
optionSet := contract.OptionSets[name]
fmt.Fprintf(&out, " %q: SettingOptionSet(\n", name)
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
out.WriteString(" options: [\n")
for _, option := range optionSet.Options {
fmt.Fprintf(&out, " SettingSuggestedOption(value: %q, introducedIn: %d),\n",
option.Value, option.IntroducedIn)
}
out.WriteString(" ]\n")
out.WriteString(" ),\n")
}
out.WriteString(" ]\n\n")
out.WriteString(" public static let definitions: [SettingKey: SettingPresentation] = [\n")
for _, def := range sortedDefinitions(contract) {
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
continue
}
fmt.Fprintf(&out, " .%s: SettingPresentation(\n", lowerFirst(identifierFor(def.Key)))
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
} else {
out.WriteString(" suggestedOptions: nil,\n")
}
if def.UnsetLabel != "" {
fmt.Fprintf(&out, " unsetLabel: %q\n", def.UnsetLabel)
} else {
out.WriteString(" unsetLabel: nil\n")
}
out.WriteString(" ),\n")
}
out.WriteString(" ]\n\n")
out.WriteString(" public static func suggestedValues(\n")
out.WriteString(" for key: SettingKey,\n")
out.WriteString(" revision: Int = SettingKey.revision\n")
out.WriteString(" ) -> [String] {\n")
out.WriteString(" guard let setID = definitions[key]?.suggestedOptions,\n")
out.WriteString(" let optionSet = optionSets[setID] else { return [] }\n")
out.WriteString(" return optionSet.options\n")
out.WriteString(" .filter { $0.introducedIn <= revision }\n")
out.WriteString(" .map(\\.value)\n")
out.WriteString(" }\n")
out.WriteString("}\n")
return out.Bytes(), nil
}
func lowerFirst(value string) string {
if value == "" {
return value
}
return strings.ToLower(value[:1]) + value[1:]
}
func sortedOptionSetNames(contract *settingscontract.Manifest) []string {
names := make([]string, 0, len(contract.OptionSets))
for name := range contract.OptionSets {
names = append(names, name)
}
sort.Strings(names)
return names
}
func quotedStrings(values []string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%q", value))
}
return strings.Join(parts, ", ")
}
func quotedScopes(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.AllowedScopes))
for _, entry := range def.AllowedScopes {
parts = append(parts, fmt.Sprintf("%q", entry.Scope))
}
return strings.Join(parts, ", ")
}
// scopeRevisions emits each scope's introduction revision, aligned with
// quotedScopes. A scope entry with no explicit tag has held since the
// definition itself appeared.
func scopeRevisions(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.AllowedScopes))
for _, entry := range def.AllowedScopes {
revision := entry.IntroducedIn
if revision == 0 {
revision = def.IntroducedIn
}
parts = append(parts, fmt.Sprintf("%d", revision))
}
return strings.Join(parts, ", ")
}
// memberRevision is the revision an enum member became a legal value at; an
// untagged member has existed since its definition.
func memberRevision(def *settingscontract.Definition, member settingscontract.EnumMember) int {
if member.IntroducedIn != 0 {
return member.IntroducedIn
}
return def.IntroducedIn
}
// boundHistory renders a widened bound's full history so an ahead-of-server
// client can recover the bound in force at an older revision. Empty when the
// bound never changed — the flattened minimum/maximum already carries it.
func boundHistory(def *settingscontract.Definition, bound *settingscontract.Bound) string {
if bound == nil || len(bound.History) < 2 {
return ""
}
parts := make([]string, 0, len(bound.History))
for _, entry := range bound.History {
revision := entry.IntroducedIn
if revision == 0 {
revision = def.IntroducedIn
}
parts = append(parts, fmt.Sprintf("{ value: %s, introducedIn: %d }",
trimFloat(entry.Value), revision))
}
return strings.Join(parts, ", ")
}
func quotedResolution(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.ResolutionOrder))
for _, scope := range def.ResolutionOrder {
parts = append(parts, fmt.Sprintf("%q", scope))
}
return strings.Join(parts, ", ")
}
func defaultLiteral(def *settingscontract.Definition) string {
if len(def.DefaultValue) == 0 {
return "null"
}
return string(bytes.TrimSpace(def.DefaultValue))
}
func jsString(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return `""`
}
return string(encoded)
}
func trimFloat(value float64) string {
return strings.TrimSuffix(fmt.Sprintf("%g", value), ".0")
}
-90
View File
@@ -1,90 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
func TestPresentationMetadataIsGeneratedForEveryClientLanguage(t *testing.T) {
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
tests := []struct {
name string
generate func() ([]byte, error)
markers []string
}{
{
name: "typescript",
generate: func() ([]byte, error) { return generateTypeScript(contract) },
markers: []string{
"export const SETTING_OPTION_SETS",
`catalog_metadata_languages`,
`suggestedOptions: "playback_subtitle_languages"`,
`unsetLabel: "None"`,
// Advisory platform tags reach the web UI so it can hide
// settings that do not apply to the device being edited.
`platforms: ["web"]`,
`platforms: ["ios", "android"]`,
},
},
{
name: "kotlin",
generate: func() ([]byte, error) {
return generateKotlin(contract, "org.siloserver.silo.model.settings")
},
markers: []string{
"object SettingPresentationMetadata",
`"playback_audio_languages" to SettingOptionSet`,
`suggestedOptions = "catalog_metadata_languages"`,
`unsetLabel = "Library default"`,
},
},
{
name: "swift",
generate: func() ([]byte, error) { return generateSwift(contract) },
markers: []string{
"public enum SettingPresentationMetadata",
`"playback_subtitle_languages": SettingOptionSet`,
`suggestedOptions: "playback_audio_languages"`,
`unsetLabel: "No preference"`,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
generated, err := tc.generate()
if err != nil {
t.Fatalf("generating: %v", err)
}
for _, marker := range tc.markers {
if !strings.Contains(string(generated), marker) {
t.Errorf("generated output omitted %q", marker)
}
}
})
}
}
func TestGeneratedOptionOrderMatchesTheManifest(t *testing.T) {
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
generated, err := generateTypeScript(contract)
if err != nil {
t.Fatalf("generating TypeScript: %v", err)
}
text := string(generated)
arabic := strings.Index(text, `{ value: "ar", introducedIn: 1 }`)
bengali := strings.Index(text, `{ value: "bn", introducedIn: 1 }`)
if arabic < 0 || bengali < 0 || arabic >= bengali {
t.Fatalf("generated option order does not preserve the manifest: ar=%d bn=%d", arabic, bengali)
}
}
+145 -157
View File
@@ -19,7 +19,6 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -96,7 +95,9 @@ import (
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/server"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/streamenforcer"
"github.com/Silo-Server/silo-server/internal/streammonitor"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/taskmanager"
taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository"
@@ -104,6 +105,7 @@ import (
"github.com/Silo-Server/silo-server/internal/taskmanager/triggers"
"github.com/Silo-Server/silo-server/internal/telemetry"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/transfers"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -309,16 +311,6 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
// still reads via the read-path pass-through, so a backfill error must never
// block boot. The sensitive-settings pass runs first so the arr
// resolve-then-encrypt pass sees consistent referenced settings.
// librarySettingsCleaner wires the per-user canonical settings cleanup the
// library delete job runs, or nil when the user store is unavailable — the
// executor treats a nil cleaner as "skip".
func librarySettingsCleaner(pool *pgxpool.Pool, stores userstore.UserStoreProvider) adminjob.LibrarySettingsCleaner {
if pool == nil || stores == nil {
return nil
}
return userstore.NewSettingValuesCleaner(auth.NewUserRepository(pool), stores)
}
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
settingsN, err := settings.BackfillSensitiveSettings(ctx)
if err != nil {
@@ -403,30 +395,10 @@ func main() {
envFile := flag.String("env", ".env", "path to .env bootstrap file")
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
migrateStatus := flag.Bool("migrate-status", false, "show database migration status and exit")
migrateDownTo := flag.Int64("migrate-down-to", -1,
"roll back every migration newer than this version and exit (the version to KEEP)")
flag.Parse()
ctx := context.Background()
// Step 0: Validate the embedded settings contract before anything can
// depend on it. A malformed or self-inconsistent manifest is a build defect,
// not a runtime condition, so failing here — loudly, before the first
// request — is the whole point: the alternative is shipping an image whose
// contract disagrees with the clients that vendored it.
contract, err := settingscontract.Load()
if err != nil {
log.Fatalf("settings contract: %v", err)
}
contractETag, err := settingscontract.ETag()
if err != nil {
log.Fatalf("settings contract: %v", err)
}
slog.Info("settings contract loaded",
"revision", contract.Revision,
"definitions", len(contract.Definitions),
"etag", contractETag)
// Step 1: Bootstrap from .env
bc, err := config.LoadBootstrap(*envFile)
if err != nil {
@@ -476,21 +448,6 @@ func main() {
return
}
if *migrateDownTo >= 0 {
// Deliberately its own flag rather than a mode of --migrate-only: this
// discards data, and several of the migrations it reverses are Go ones
// the goose CLI cannot reach, so it is the only way to undo them
// short of restoring a backup.
migCtx, migCancel := database.MigrationContext(ctx)
migErr := database.MigrateDownTo(migCtx, pool, migrations.FS, "sql", *migrateDownTo)
migCancel()
if migErr != nil {
log.Fatalf("failed to roll back migrations: %v", migErr)
}
slog.Info("database migrations rolled back", "kept_through_version", *migrateDownTo)
return
}
if *migrateOnly {
migCtx, migCancel := database.MigrationContext(ctx)
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
@@ -715,9 +672,43 @@ func main() {
tracker.Cleanup(cleanupCtx)
}()
// Kill switch: edges consult this per request. Central writes revocations
// (over-cap, admin terminate, account revocation) which propagate here via
// Redis pub/sub + poll. Shares the offload Redis.
revStore := streamrevoke.New(streamrevoke.Options{Redis: redisClient, Bus: eventBus})
revStore.StartSync(appCtx)
var handler http.Handler
if mode == "proxy" {
srv := proxy.NewServer(watcher, tracker)
srv.SetRevocationStore(revStore)
// Resolve the viewer's address through the operator's trusted-proxy
// boundary, as the native surface does. Without this the edge records
// the connecting peer, which behind an ingress or load balancer is the
// same address for every viewer — collapsing the admin session view and
// any per-viewer analysis to one indistinguishable client.
edgeResolver := clientip.NewResolver(nil)
if cidrs, cidrErr := clientip.LoadTrustedCIDRs(appCtx, settingsRepo); cidrErr != nil {
// Fail closed on trust, not on startup: an empty trusted list means
// forwarding headers are ignored entirely, so a spoofed header can
// never be believed. The edge falls back to the connecting peer.
slog.Warn("edge client-IP trust list unavailable; ignoring forwarding headers",
"component", "proxy", "error", cidrErr)
} else {
edgeResolver.UpdateTrustedCIDRs(cidrs)
}
// Keep the boundary current: the setting is hot-reloadable on central,
// so the edge must not need a restart to follow it.
watcher.OnChange(func(_, _ *config.Config) {
cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo)
if loadErr != nil {
slog.Warn("edge client-IP trust list reload failed",
"component", "proxy", "error", loadErr)
return
}
edgeResolver.UpdateTrustedCIDRs(cidrs)
})
srv.SetClientIPResolver(edgeResolver)
handler = srv.Handler()
} else {
srv := transcodenode.NewServer(watcher, tracker)
@@ -729,6 +720,7 @@ func main() {
// Reclaim orphaned transcode dirs at boot and hourly thereafter, bound
// to appCtx so it stops on shutdown.
srv.StartOrphanSweeper(appCtx)
srv.SetRevocationStore(revStore)
handler = srv.Handler()
}
@@ -792,6 +784,34 @@ func main() {
defer func() { _ = apiRedisClient.Close() }()
}
// One process-local registry is shared by every download-class serving
// surface and the admin view. It is intentionally separate from live streams.
maxUserTransfers, maxUserTransfersErr := transfers.ParseMaxPerUser(settings[transfers.MaxPerUserSetting])
if maxUserTransfersErr != nil {
slog.Warn("invalid max user concurrent transfers setting; using default",
"component", "transfers",
"setting", transfers.MaxPerUserSetting,
"value", settings[transfers.MaxPerUserSetting],
"error", maxUserTransfersErr,
)
maxUserTransfers, _ = transfers.ParseMaxPerUser("")
}
transferMonitoring := newTransferRegistryComposition(maxUserTransfers)
// Central-side kill switch: written by the async enforcer, admin terminate,
// and account revocation; edges enforce it via Redis pub/sub + poll. Memory-
// only when Redis is absent (single-node integrated). The Postgres durable
// mirror lets the kill list survive a server restart AND a Redis flush, so a
// restart-resilient stream (PR #174) cannot be reconstructed and re-served
// after being killed. NewPostgresDurableStore returns a true nil interface
// when the pool is nil, degrading cleanly to memory/Redis-only.
streamRevocation := streamrevoke.New(streamrevoke.Options{
Redis: apiRedisClient,
Bus: eventBus,
Durable: streamrevoke.NewPostgresDurableStore(pool),
})
streamRevocation.StartSync(appCtx)
// Assigned below once the trusted-proxy config is seeded; captured by the
// OnServerSettingUpdated closure, which only runs on admin requests after
// startup completes.
@@ -812,6 +832,7 @@ func main() {
SecretCipher: dataCipher,
EventBus: eventBus,
RedisClient: apiRedisClient,
RevocationStore: streamRevocation,
LogStreamHub: logStreamHub,
RealtimeHub: realtimeHub,
EventsHub: eventsHub,
@@ -845,6 +866,7 @@ func main() {
configWatcher.RequestReload()
},
}
transferMonitoring.wireAPI(&deps)
accessGroupStore := access.NewGroupStore(pool)
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
@@ -894,10 +916,8 @@ 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)
}
@@ -907,8 +927,10 @@ func main() {
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
watchProviderService = watchsync.NewService(
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
watchProviderRegistry,
)
deps.WatchProviderService = watchProviderService
}
@@ -976,7 +998,7 @@ func main() {
s.SetLiteraryWorkLinker(literaryWorkService)
s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB))
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second)
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
slog.Info("scanner initialized")
}
@@ -1120,15 +1142,6 @@ 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) {
@@ -1733,6 +1746,7 @@ func main() {
// Build the reconciler early enough that playback handlers can trigger
// immediate session syncs after start/stop events.
nodeIdentity := resolveNodeIdentity()
instanceID := nodeIdentity + ":" + uuid.NewString()
var reconciler *worker.Reconciler
var heartbeatWriter *worker.HeartbeatWriter
@@ -1758,6 +1772,64 @@ func main() {
}
deps.SessionSyncer = reconciler
// Async brain: off the hot path, trims over-cap streams by writing
// revocations the edges enforce. Source is the authoritative monitoring
// picture — Redis for multi-node, the in-process session manager for
// integrated. Fails open on limit-lookup errors.
// Union of (a) the in-process session manager — authoritative for streams
// this node serves directly, integrated or not — and (b) the edge Redis
// records — authoritative for offloaded/edge-served streams. Deduped by
// session id. Using the union (not "Redis when present, else manager")
// fixes the case where integrated mode has Redis configured but writes no
// silo:sessions:* keys, which would otherwise blind the enforcer.
// The mapping (Session → monitoring record, carrying route + client identity
// + progress) is shared with the admin session list via streammonitor so there is
// one Session→record definition. NodeName marks the serving host so
// integrated streams aren't shown node-less.
localSource := streammonitor.NewFuncSource(func(ctx context.Context) ([]nodesessions.SessionInfo, error) {
return streammonitor.LiveLocalSessions(sessionMgr, nodeIdentity), nil
})
var monitorSource streammonitor.Source = localSource
if apiRedisClient != nil {
publisher := nodesessions.NewPublisher(apiRedisClient, instanceID, nodeIdentity)
publisher.Start(appCtx, 10*time.Second, func() []nodesessions.SessionInfo {
return streammonitor.LiveLocalSessions(sessionMgr, nodeIdentity)
})
defer func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cleanupCancel()
publisher.Cleanup(cleanupCtx)
}()
monitorSource = streammonitor.NewMultiSource(localSource, streammonitor.NewRedisSource(apiRedisClient))
}
// Resolve caps through the session manager so the enforcer and synchronous
// admission share one provider (installed by the API wiring) and can never
// disagree about a user's effective, group-merged cap.
overCapRevocationTTL, ttlErr := streamenforcer.ParseRevocationTTL(
settings[streamenforcer.RevocationTTLSetting],
)
if ttlErr != nil {
slog.Warn("invalid over-cap revocation TTL; using default",
"setting", streamenforcer.RevocationTTLSetting,
"value", settings[streamenforcer.RevocationTTLSetting],
"default", streamenforcer.DefaultRevocationTTL,
"error", ttlErr)
overCapRevocationTTL = streamenforcer.DefaultRevocationTTL
}
enforcer := streamenforcer.NewWithRevocationTTL(monitorSource, func(ctx context.Context, userID int) (int, error) {
limits, err := sessionMgr.EffectiveLimits(ctx, userID)
if err != nil {
return 0, err
}
return limits.MaxStreams, nil
}, streamRevocation, 0, overCapRevocationTTL)
if apiRedisClient != nil {
enforcer.SetCoordinator(streamenforcer.NewRedisCoordinator(
apiRedisClient, instanceID, streamenforcer.DefaultInterval,
))
}
enforcer.Start(appCtx)
nodeURL := fmt.Sprintf("http://%s%s", nodeIdentity, cfg.Server.Listen)
heartbeatWriter = worker.NewHeartbeatWriter(deps.DB, nodeIdentity, mode, nodeURL)
}
@@ -2096,11 +2168,6 @@ func main() {
taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem))
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem))
}
if userStoreProvider != nil {
taskMgr.Register(tasks.NewSettingMutationsRetentionTask(userstore.NewSettingMutationSweeper(
auth.NewUserRepository(deps.DB), userStoreProvider,
)))
}
if matchWorker != nil {
taskMgr.Register(tasks.NewMatchMediaTask(matchWorker))
}
@@ -2288,7 +2355,9 @@ func main() {
Detail: absDetailSvc,
SessionMgr: sessionMgr,
SessionSyncer: deps.SessionSyncer,
Revocation: streamRevocation,
}
transferMonitoring.wireABS(&absHDeps)
absH := audiobooksService.BuildABSHandler(absHDeps)
deps.ABSHandler = absH
}
@@ -2401,6 +2470,12 @@ func main() {
if compatServer != nil {
compatServer.SessionStore().DeleteByUserID(userID)
}
// Also revoke this user's live stream credentials so a session revocation
// (disable, password change, permission change) kills in-flight playback
// at the edge within one propagation/poll interval, not just compat login.
if err := streamRevocation.RevokeUser(ctx, userID, "user_sessions_revoked"); err != nil {
slog.Warn("revoke user streams failed", "user_id", userID, "error", err)
}
}
distFS, fsErr := fs.Sub(siloweb.DistFS, "dist")
@@ -2487,8 +2562,7 @@ func main() {
deps.S3Private,
itemRefreshExecutor,
libraryRefreshExecutor,
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo,
librarySettingsCleaner(deps.DB, userStoreProvider)),
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo),
adminjob.NewImageCacheCleanupExecutor(deps.S3Public),
templateBundleApplyExecutor,
deps.RealtimeHub,
@@ -2540,6 +2614,7 @@ func main() {
RecipeNodeStore: noderecipe.NewStore(apiRedisClient, 0),
SessionSyncer: deps.SessionSyncer,
}
transferMonitoring.wireJellycompat(&compatDeps)
// Wire direct dependencies when DB is available.
if deps.DB != nil {
@@ -2574,6 +2649,7 @@ func main() {
compatDeps.DetailSvc = detailSvc
compatDeps.FolderRepo = folderRepo
compatDeps.SessionMgr = sessionMgr
compatDeps.RevocationStore = streamRevocation
compatDeps.UserStoreProvider = userStoreProvider
compatDeps.WatchCompletionObserver = deps.WatchCompletionObserver
compatDeps.SettingsRepo = settingsRepo
@@ -3098,99 +3174,11 @@ 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,8 +1,6 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -10,12 +8,9 @@ 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) {
@@ -187,44 +182,3 @@ 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())
}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/audiobooks"
"github.com/Silo-Server/silo-server/internal/jellycompat"
"github.com/Silo-Server/silo-server/internal/transfers"
)
// transferRegistryComposition owns the one process-local registry and keeps
// independently constructed protocol surfaces from accidentally diverging.
type transferRegistryComposition struct {
registry *transfers.Registry
}
func newTransferRegistryComposition(maxPerUser int) *transferRegistryComposition {
return &transferRegistryComposition{registry: transfers.NewWithOptions(transfers.Options{MaxPerUser: &maxPerUser})}
}
func (c *transferRegistryComposition) wireAPI(deps *api.Dependencies) {
deps.TransferRegistry = c.registry
}
func (c *transferRegistryComposition) wireJellycompat(deps *jellycompat.Dependencies) {
deps.TransferRegistry = c.registry
}
func (c *transferRegistryComposition) wireABS(deps *audiobooks.ABSHandlerDeps) {
deps.Transfers = c.registry
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"testing"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/audiobooks"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
func TestTransferRegistryCompositionSharesOneInstance(t *testing.T) {
composition := newTransferRegistryComposition(24)
var apiDeps api.Dependencies
var compatDeps jellycompat.Dependencies
var absDeps audiobooks.ABSHandlerDeps
composition.wireAPI(&apiDeps)
composition.wireJellycompat(&compatDeps)
composition.wireABS(&absDeps)
if apiDeps.TransferRegistry == nil {
t.Fatal("API/native/admin transfer registry is nil")
}
if got := apiDeps.TransferRegistry.MaxPerUser(); got != 24 {
t.Fatalf("max per-user transfers = %d, want 24", got)
}
if apiDeps.TransferRegistry != compatDeps.TransferRegistry {
t.Fatal("jellycompat received a different transfer registry")
}
if apiDeps.TransferRegistry != absDeps.Transfers {
t.Fatal("ABS received a different transfer registry")
}
}
-696
View File
@@ -1,696 +0,0 @@
{
"fixture_version": 1,
"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.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
},
{
"key": "playback.subtitle_language",
"scope": "profile_library",
"profile_id": "p1",
"library_id": 7,
"value": "fr"
},
{
"key": "playback.subtitle_language",
"scope": "profile_series",
"profile_id": "p1",
"series_id": "s-101",
"value": "ja"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" }
]
},
{
"name": "resolution_order_library_beats_device",
"description": "Without a series row, the library row wins over the device and profile rows.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
},
{
"key": "playback.subtitle_language",
"scope": "profile_library",
"profile_id": "p1",
"library_id": 7,
"value": "fr"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" }
]
},
{
"name": "resolution_order_device_beats_profile",
"description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
}
],
"expected": [
{ "key": "playback.subtitle_language", "value": "de", "source": "profile_device" }
]
},
{
"name": "resolution_order_profile_alone",
"description": "A profile row alone resolves at profile scope.",
"keys": ["playback.subtitle_language"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }
],
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
},
{
"name": "device_override_beats_profile_for_quality",
"description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "720p"
},
{
"key": "playback.preferred_quality",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "1080p"
}
],
"expected": [
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }
]
},
{
"name": "missing_device_identity_drops_device_scope",
"description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.",
"keys": ["playback.subtitle_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "de"
}
],
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
},
{
"name": "foreign_identity_rows_never_resolve",
"description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.",
"keys": ["playback.subtitle_language"],
"context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] },
"stored": [
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" },
{
"key": "playback.subtitle_language",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d2",
"value": "yy"
},
{
"key": "playback.subtitle_language",
"scope": "profile_series",
"profile_id": "p1",
"series_id": "s-other",
"value": "zz"
}
],
"expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }]
},
{
"name": "absent_values_resolve_to_contract_defaults",
"description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.",
"keys": [
"playback.subtitle_mode",
"playback.show_forced_subtitles",
"playback.next_up_prompt_seconds",
"playback.audio_language"
],
"context": { "profile_id": "p1", "device_id": "d1" },
"expected": [
{ "key": "playback.subtitle_mode", "value": "auto", "source": "default" },
{ "key": "playback.show_forced_subtitles", "value": true, "source": "default" },
{ "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" },
{ "key": "playback.audio_language", "value": null, "source": "default" }
]
},
{
"name": "batch_resolves_each_key_independently",
"description": "One batch, three keys, three different sources: a device override, a profile value, and a default.",
"keys": [
"playback.preferred_quality",
"playback.subtitle_mode",
"playback.audio_language"
],
"context": { "profile_id": "p1", "device_id": "d1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": "1080p"
},
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" }
],
"expected": [
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" },
{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" },
{ "key": "playback.audio_language", "value": null, "source": "default" }
]
},
{
"name": "ceiling_caps_stored_quality_and_reports_the_stored_value",
"description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "2160p"
}
],
"constraints": { "max_playback_quality": "1080p" },
"expected": [
{
"key": "playback.preferred_quality",
"value": "1080p",
"source": "profile",
"constrained": true,
"stored_value": "2160p",
"constraint_kind": "ceiling"
}
]
},
{
"name": "ceiling_leaves_quality_under_the_cap_alone",
"description": "A value at or under the cap passes through untouched and is not reported as constrained.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "720p"
}
],
"constraints": { "max_playback_quality": "1080p" },
"expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }]
},
{
"name": "ceiling_ranks_auto_below_every_cap",
"description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "auto"
}
],
"constraints": { "max_playback_quality": "480p" },
"expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }]
},
{
"name": "ceiling_caps_original_as_the_highest_member",
"description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.",
"keys": ["playback.preferred_quality"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.preferred_quality",
"scope": "profile",
"profile_id": "p1",
"value": "original"
}
],
"constraints": { "max_playback_quality": "2160p" },
"expected": [
{
"key": "playback.preferred_quality",
"value": "2160p",
"source": "profile",
"constrained": true,
"stored_value": "original",
"constraint_kind": "ceiling"
}
]
},
{
"name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it",
"description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
],
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "max_bitrate_kbps",
"constraint": "ceiling"
}
],
"constraints": { "max_bitrate_kbps": 8000 },
"expected": [
{
"key": "playback.max_bitrate_kbps",
"value": 8000,
"source": "profile",
"constrained": true,
"stored_value": null,
"constraint_kind": "ceiling"
}
]
},
{
"name": "default_null_bitrate_is_capped_by_a_ceiling",
"description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "max_bitrate_kbps",
"constraint": "ceiling"
}
],
"constraints": { "max_bitrate_kbps": 8000 },
"expected": [
{
"key": "playback.max_bitrate_kbps",
"value": 8000,
"source": "default",
"constrained": true,
"stored_value": null,
"constraint_kind": "ceiling"
}
]
},
{
"name": "floor_leaves_an_unbounded_bitrate_alone",
"description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.",
"keys": ["playback.max_bitrate_kbps"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
],
"constraint_bindings": [
{
"key": "playback.max_bitrate_kbps",
"policy_input": "min_bitrate_kbps",
"constraint": "floor"
}
],
"constraints": { "min_bitrate_kbps": 8000 },
"expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }]
},
{
"name": "allowlist_falls_back_when_the_default_is_outside_the_list",
"description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [
{
"key": "catalog.metadata_language",
"value": "en",
"source": "default",
"constrained": true,
"stored_value": null,
"constraint_kind": "allowlist"
}
]
},
{
"name": "allowlist_replaces_a_forbidden_choice",
"description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" }
],
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [
{
"key": "catalog.metadata_language",
"value": "en",
"source": "profile",
"constrained": true,
"stored_value": "ja",
"constraint_kind": "allowlist"
}
]
},
{
"name": "allowlist_passes_a_permitted_choice",
"description": "A stored value inside the allowlist passes through untouched.",
"keys": ["catalog.metadata_language"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" }
],
"constraint_bindings": [
{
"key": "catalog.metadata_language",
"policy_input": "allowed_metadata_languages",
"constraint": "allowlist"
}
],
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
},
{
"name": "metadata_language_exceptions_resolve_as_one_object",
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
"keys": ["catalog.metadata_language_overrides"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "catalog.metadata_language_overrides",
"scope": "profile",
"profile_id": "p1",
"value": { "ja": "en", "no": "x-silo-original" }
}
],
"expected": [
{
"key": "catalog.metadata_language_overrides",
"value": { "ja": "en", "no": "x-silo-original" },
"source": "profile"
}
]
},
{
"name": "locked_replaces_a_differing_choice",
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"stored": [
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" }
],
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [
{
"key": "playback.subtitle_mode",
"value": "always",
"source": "profile",
"constrained": true,
"stored_value": "off",
"constraint_kind": "locked"
}
]
},
{
"name": "locked_leaves_an_equal_value_unconstrained",
"description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.subtitle_mode",
"scope": "profile",
"profile_id": "p1",
"value": "always"
}
],
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }]
},
{
"name": "locked_replaces_the_contract_default",
"description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.",
"keys": ["playback.subtitle_mode"],
"context": { "profile_id": "p1" },
"constraint_bindings": [
{
"key": "playback.subtitle_mode",
"policy_input": "forced_subtitle_mode",
"constraint": "locked"
}
],
"constraints": { "forced_subtitle_mode": "always" },
"expected": [
{
"key": "playback.subtitle_mode",
"value": "always",
"source": "default",
"constrained": true,
"stored_value": "auto",
"constraint_kind": "locked"
}
]
},
{
"name": "subtitle_appearance_ignores_content_scopes",
"description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.",
"keys": ["playback.subtitle_appearance"],
"context": {
"profile_id": "p1",
"device_id": "d1",
"library_ids": [7],
"series_ids": ["s-101"]
},
"stored": [
{
"key": "playback.subtitle_appearance",
"scope": "profile",
"profile_id": "p1",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
},
{
"key": "playback.subtitle_appearance",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": { "fontSize": "xxlarge", "position": "top" }
}
],
"expected": [
{
"key": "playback.subtitle_appearance",
"value": { "fontSize": "xxlarge", "position": "top" },
"source": "profile_device"
}
]
},
{
"name": "subtitle_appearance_falls_to_profile_without_device",
"description": "Without a device identity the profile's appearance object answers, unmerged.",
"keys": ["playback.subtitle_appearance"],
"context": { "profile_id": "p1" },
"stored": [
{
"key": "playback.subtitle_appearance",
"scope": "profile",
"profile_id": "p1",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
},
{
"key": "playback.subtitle_appearance",
"scope": "profile_device",
"profile_id": "p1",
"device_id": "d1",
"value": { "fontSize": "xxlarge", "position": "top" }
}
],
"expected": [
{
"key": "playback.subtitle_appearance",
"value": { "fontSize": "medium", "fontColor": "#ffcc00" },
"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"
}
]
}
]
}
-18
View File
@@ -1,18 +0,0 @@
// Package settingsv1 embeds the canonical cross-platform user settings
// contract so the server binary carries the exact bytes it was built from.
//
// This package deliberately contains nothing but the embed directive. The
// contract files are the artifact clients vendor and generate bindings from, so
// they live at this stable path rather than inside an internal package; the
// embed has to sit beside them because go:embed cannot reach outside its own
// directory.
//
// Loading, validation, and lookup live in internal/settingscontract.
package settingsv1
import "embed"
// FS holds manifest.json, manifest.schema.json, and schemas/.
//
//go:embed manifest.json manifest.schema.json schemas
var FS embed.FS
File diff suppressed because it is too large Load Diff
-344
View File
@@ -1,344 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/manifest.schema.json",
"title": "Silo cross-platform user settings manifest",
"description": "Canonical contract for every production, user-facing setting. See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md.",
"type": "object",
"additionalProperties": false,
"required": ["api_version", "revision", "definitions"],
"properties": {
"api_version": {
"description": "Settings protocol version. Changes only for a change no revision rule can express.",
"type": "integer",
"minimum": 1
},
"revision": {
"description": "Monotonically increasing integer bumped by every manifest PR.",
"type": "integer",
"minimum": 1
},
"option_sets": {
"description": "Advisory ordered vocabularies for open-value controls. They suggest values but never constrain the corresponding value schema.",
"type": "object",
"propertyNames": { "$ref": "#/$defs/optionSetName" },
"additionalProperties": { "$ref": "#/$defs/optionSet" }
},
"definitions": {
"type": "array",
"items": { "$ref": "#/$defs/definition" }
}
},
"$defs": {
"settingKey": {
"description": "Lowercase dot-separated identifier. Canonical names do not encode a platform.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$",
"maxLength": 128
},
"revisionRef": {
"description": "Manifest revision in which this element was introduced.",
"type": "integer",
"minimum": 1
},
"optionSetName": {
"description": "Stable snake_case identifier for a reusable presentation vocabulary.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"maxLength": 128
},
"suggestedOption": {
"description": "One advisory value. Labels are localized by clients from the stable wire value.",
"type": "object",
"additionalProperties": false,
"required": ["value", "introduced_in"],
"properties": {
"value": { "type": "string", "minLength": 1 },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
},
"optionSet": {
"description": "An ordered advisory value floor for an open setting. The server validates that its type matches every referring definition.",
"type": "object",
"additionalProperties": false,
"required": ["type", "options"],
"properties": {
"type": { "const": "language_tag" },
"options": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/suggestedOption" }
}
}
},
"scopeName": {
"description": "Storage identity a value attaches to. Whether a given scope is legal for a definition depends on its persistence class, which internal/settingscontract enforces.",
"type": "string",
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
"client_local"
]
},
"scopeEntry": {
"description": "A scope, optionally tagged with the revision that added it to this definition.",
"oneOf": [
{ "$ref": "#/$defs/scopeName" },
{
"type": "object",
"additionalProperties": false,
"required": ["scope"],
"properties": {
"scope": { "$ref": "#/$defs/scopeName" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
]
},
"integerBound": {
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
"oneOf": [
{ "type": "integer" },
{
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": "integer" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
}
]
},
"numberBound": {
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
"oneOf": [
{ "type": "number" },
{
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": "number" },
"introduced_in": { "$ref": "#/$defs/revisionRef" }
}
}
}
]
},
"enumMember": {
"description": "Enum members are objects so members added later can carry their own revision.",
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": { "type": ["string", "integer", "boolean"] },
"label": { "type": "string" },
"introduced_in": { "$ref": "#/$defs/revisionRef" },
"deprecated": { "type": "boolean", "default": false }
}
},
"valueSchema": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "const": "boolean" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "minimum", "maximum"],
"properties": {
"type": { "const": "integer" },
"minimum": { "$ref": "#/$defs/integerBound" },
"maximum": { "$ref": "#/$defs/integerBound" },
"step": { "type": "integer", "exclusiveMinimum": 0 },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "minimum", "maximum"],
"properties": {
"type": { "const": "number" },
"minimum": { "$ref": "#/$defs/numberBound" },
"maximum": { "$ref": "#/$defs/numberBound" },
"step": { "type": "number", "exclusiveMinimum": 0 },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "max_length"],
"properties": {
"type": { "const": "string" },
"min_length": { "type": "integer", "minimum": 0, "default": 0 },
"max_length": { "type": "integer", "minimum": 1 },
"pattern": { "type": "string", "format": "regex" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "values"],
"properties": {
"type": { "const": "enum" },
"values": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/enumMember" }
},
"ordered": {
"description": "Members form a meaningful progression. Required for ceiling/floor constraints.",
"type": "boolean",
"default": false
},
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "const": "language_tag" },
"nullable": { "type": "boolean", "default": false }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "schema_ref"],
"properties": {
"type": { "const": "object" },
"schema_ref": {
"description": "Filename under contracts/settings/v1/schemas/.",
"type": "string",
"pattern": "^[a-z0-9-]+\\.json$"
},
"nullable": { "type": "boolean", "default": false }
}
}
]
},
"constraint": {
"description": "Binding to a policy input that constrains this setting at resolution time.",
"type": "object",
"additionalProperties": false,
"required": ["policy_input", "constraint"],
"properties": {
"policy_input": {
"description": "Field name produced by internal/policy.",
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"constraint": {
"type": "string",
"enum": ["ceiling", "floor", "allowlist", "locked"]
}
}
},
"definition": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"introduced_in",
"persistence",
"allowed_scopes",
"resolution_order",
"value_schema",
"default_value",
"category",
"label",
"description"
],
"properties": {
"key": { "$ref": "#/$defs/settingKey" },
"introduced_in": { "$ref": "#/$defs/revisionRef" },
"persistence": {
"type": "string",
"enum": ["remote", "client_local"]
},
"allowed_scopes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/scopeEntry" }
},
"resolution_order": {
"description": "Most specific first. Must end with \"default\".",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"account",
"profile",
"profile_client",
"profile_device",
"profile_library",
"profile_series",
"client_local",
"default"
]
}
},
"value_schema": { "$ref": "#/$defs/valueSchema" },
"default_value": {},
"constrained_by": { "$ref": "#/$defs/constraint" },
"platforms": {
"description": "Advisory UI metadata. Absent means \"expected everywhere\". Never server-enforced.",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["web", "ios", "tvos", "macos", "android", "android_tv"]
}
},
"category": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"label": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"unit": { "type": "string" },
"recommended_control": {
"type": "string",
"enum": ["switch", "select", "slider", "stepper", "text", "color", "panel"]
},
"suggested_options": {
"description": "Advisory option-set reference for an open-value control. It does not turn the value schema into an enum.",
"$ref": "#/$defs/optionSetName"
},
"unset_label": {
"description": "Context-specific label for the nullable/unset row in a generated control.",
"type": "string",
"minLength": 1
},
"deprecated": { "type": "boolean", "default": false },
"notes": {
"description": "Maintainer commentary. Not served in the public manifest.",
"type": "string"
}
}
}
}
}
@@ -1,80 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-overlays.json",
"title": "Card overlay preferences",
"description": "Badges painted on poster cards. Mirrors CardOverlayPrefs in web/src/lib/overlays/types.ts.",
"type": "object",
"additionalProperties": false,
"required": ["version", "preset", "order", "items"],
"properties": {
"version": { "const": 2 },
"preset": {
"type": "string",
"enum": ["minimal", "classic", "vibrant", "pill", "square"]
},
"order": {
"description": "Explicit render order. Empty means use the registry's own order.",
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "$ref": "#/$defs/overlayId" }
},
"items": {
"type": "object",
"propertyNames": { "$ref": "#/$defs/overlayId" },
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "position"],
"properties": {
"enabled": { "type": "boolean" },
"position": {
"type": "string",
"enum": ["top-left", "top-right", "bottom-left", "bottom-right"]
},
"accentColor": {
"description": "Hex colour. Absent means the overlay's own default accent.",
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
"showIcon": {
"description": "Absent means inherit from the preset.",
"type": "boolean"
}
}
}
}
},
"$defs": {
"overlayId": {
"type": "string",
"enum": [
"resolution",
"hdr",
"resolution_hdr",
"audio",
"audio_channels",
"video_codec",
"container",
"aspect_ratio",
"release_type",
"edition",
"multi_audio",
"multi_sub",
"rating_imdb",
"rating_tmdb",
"rating_rt",
"rating_rt_audience",
"content_rating",
"year",
"runtime",
"original_language",
"studio",
"network",
"show_status",
"imdb_top_250",
"rt_certified_fresh"
]
}
}
}
@@ -1,19 +0,0 @@
{
"$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"]
}
}
}
@@ -1,13 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-id-list.json",
"title": "Library id list",
"description": "An ordered, duplicate-free list of library ids. Backs both ui.disabled_library_ids and ui.library_order; the web client normalizes with normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and anything below 1.",
"type": "array",
"maxItems": 512,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 1
}
}
@@ -1,32 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-page-state.json",
"title": "Library page state",
"description": "Remembered per-library browse state. Mirrors web/src/hooks/queries/libraryPageState.ts.",
"type": "object",
"additionalProperties": false,
"required": ["version", "libraries"],
"properties": {
"version": { "const": 1 },
"libraries": {
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[0-9]+$"
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["search"],
"properties": {
"search": {
"type": "string",
"description": "Serialized URLSearchParams from serializeLibraryPageSearchParams. An advanced view encodes each filter rule as three groups[i][rules][j][...] keys, so the length grows about 150 characters per rule: measured at 216 for one rule, 518 for three, 820 for five. The bound has to clear what the current unvalidated endpoint already stores, or these rows fail the migration.",
"maxLength": 4096
}
}
},
"maxProperties": 512
}
}
}
@@ -1,20 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/metadata-language-overrides.json",
"title": "Metadata language exceptions",
"description": "Maps a media item's canonical original-language code to the metadata language a profile wants for that item. x-silo-original means use the source language itself.",
"type": "object",
"propertyNames": {
"description": "Canonical ISO 639 language code as stored in media_items.original_language.",
"type": "string",
"pattern": "^[a-z]{2,3}$"
},
"additionalProperties": {
"description": "A BCP 47 target language or the x-silo-original private-use sentinel.",
"type": "string",
"minLength": 2,
"maxLength": 255,
"pattern": "^([a-zA-Z]{2,3}(-[a-zA-Z]{3}){0,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-([0-9a-zA-Z]{5,8}|[0-9][0-9a-zA-Z]{3}))*(-[0-9a-wy-zA-WY-Z](-[0-9a-zA-Z]{2,8})+)*(-[xX](-[0-9a-zA-Z]{1,8})+)?|[xX](-[0-9a-zA-Z]{1,8})+)$"
},
"maxProperties": 256
}
@@ -1,62 +0,0 @@
{
"$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" }
}
}
}
}
@@ -1,86 +0,0 @@
{
"$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" }
}
}
}
}
@@ -1,27 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/sidebar-pins.json",
"title": "Sidebar pins",
"description": "Sections and collections pinned into the sidebar, grouped by the library they belong to. Mirrors SidebarPins in web/src/api/types.ts.",
"type": "object",
"propertyNames": {
"description": "The group the pins sit under — a library id, or a well-known group name.",
"type": "string",
"maxLength": 64
},
"maxProperties": 512,
"additionalProperties": {
"type": "array",
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id", "label"],
"properties": {
"type": { "type": "string", "enum": ["section", "collection"] },
"id": { "type": "string", "minLength": 1, "maxLength": 128 },
"label": { "type": "string", "maxLength": 256 }
}
}
}
}
@@ -1,45 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/subtitle-appearance.json",
"title": "Subtitle appearance",
"description": "Rendering appearance for subtitle tracks. Shared by the web, Apple and Android players; where they disagree the wider vocabulary wins, because a value a shipped client can already produce must stay storable. A stored value is a sparse override: every property is optional, and a consumer merges what is present over this definition's default_value, which is complete. Requiring all nine would invalidate the partial objects the current API already stores and round-trips, so the migration would have to quarantine real user preferences. Resolution across scopes is unchanged and still first-wins — a device override replaces the profile's object rather than merging with it — because a device override means \"draw subtitles this way on this screen\", not \"amend the profile\".",
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"fontSize": {
"type": "string",
"enum": ["small", "medium", "large", "xlarge", "xxlarge"]
},
"fontFamily": {
"description": "A font family name. Not an enum: the Apple clients offer every family CTFontManagerCopyAvailableFontFamilyNames reports and store the chosen name verbatim, so restricting this to the web's three generic families would invalidate the stored appearance of every user who picked a real font. Family names are not ASCII — ヒラギノ角ゴ ProN is a stock macOS family — so the pattern excludes rather than allowlists: no control characters, quotes, separators, parentheses or braces, which keeps a value safe to interpolate into CSS or a platform font lookup while accepting any real family name.",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[^\\x00-\\x1f\"'(){};:,\\\\/ ][^\\x00-\\x1f\"'(){};:,\\\\/]*$"
},
"fontColor": { "$ref": "#/$defs/hexColor" },
"backgroundColor": { "$ref": "#/$defs/hexColor" },
"backgroundStyle": {
"type": "string",
"enum": ["box", "shadow", "outline", "none"]
},
"backgroundOpacity": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"textOutline": { "type": "boolean" },
"textOutlineColor": { "$ref": "#/$defs/hexColor" },
"position": {
"type": "string",
"enum": ["bottom", "lower-third", "top"]
}
},
"$defs": {
"hexColor": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
@@ -1,18 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/theme-var-overrides.json",
"title": "Theme variable overrides",
"description": "Sparse map of theme token to CSS value. Token names mirror web/src/lib/themeTokens.ts; values are bounded to keep this from becoming an untyped blob. The per-value bound is sized to what the web importer already accepts and stores: computed multi-stop gradients routinely pass 128 characters, so a tighter bound would invalidate themes users already imported.",
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"maxLength": 64
},
"additionalProperties": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"maxProperties": 256
}
+2
View File
@@ -123,6 +123,7 @@ 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:
@@ -168,6 +169,7 @@ services:
volumes:
silo-dev-air-tmp:
silo-dev-audiobook-covers:
silo-dev-compat:
silo-dev-go-build:
silo-dev-go-mod:
-10
View File
@@ -1,10 +0,0 @@
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
+7 -6
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
POSTGRES_DB: ${POSTGRES_DB:-silo}
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
- "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:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
- "6379:6379"
volumes:
- ${SILO_DATA_ROOT:-/opt/silo}/redis:/data
healthcheck:
@@ -56,10 +56,6 @@ 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
@@ -75,10 +71,12 @@ 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:
@@ -88,6 +86,9 @@ 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,20 +31,6 @@ 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
@@ -0,0 +1,628 @@
# Playback Paths — Monitoring & Kill-Switch Coverage Matrix
> Reference checklist for stream monitoring + revocation kill-switch coverage.
> Check every change to playback/monitoring/revocation against this. As-built
> companion to the design plan
> [`2026-07-04-stream-monitoring-and-kill-switch.md`](../superpowers/plans/2026-07-04-stream-monitoring-and-kill-switch.md)
> (that doc is the intent; this one is the shipped coverage). Status as of
> 2026-07-05: monitoring, enforcement, and the async over-cap enforcer are all
> shipped across two code commits — the *monitoring* commit (`feat(playback):
> server-observed stream monitoring`) and the *kill-switch* commit
> (`feat(playback): stream kill switch + async over-cap enforcer`). GAP-1..GAP-4
> are all RESOLVED (see Findings): GAP-4 (kill list did not survive a restart) was
> closed by wiring a durable Postgres mirror, and GAP-3 (in-flight cut for
> integrated direct-play) was closed by adding `Unwrap()` to the native/compat
> middleware writers so the cut reaches the socket. A post-review hardening pass
> then closed GAP-5..GAP-9 found by adversarial re-review: user-kill cutoff
> semantics, compat manifest/subtitle/mint-bypass coverage, terminate-by-id,
> in-flight download cut, admin-list dedupe, and the metered-writer sendfile chain
> (see Findings). That hardening is folded into the two code commits above — the
> branch is organized as monitoring → kill-switch → this docs commit, not as a
> running series of fix commits. (This doc references sibling commits by role, not
> SHA — squash/rebase rewrites hashes, so a SHA citation goes stale the moment the
> branch is amended.)
>
> **Update 2026-07-29 — two-model review round (Claude Opus 5 + Codex gpt-5.6-sol).**
> A second adversarial pass over the rebased branch found **six open gaps**, none of
> which the branch's own tests caught: GAP-10 (ABS in-flight cuts no-op because
> `statusRecorder` lacks `Unwrap`), GAP-11 (ebook/comic/PDF serving is neither
> observed nor killable), GAP-12 (the rolling write deadline can erase a revocation
> cut), GAP-13 (`mergeStreams` discards edge `BytesServed`), GAP-14
> (transfer-registry saturation serves unmonitored), GAP-15 (edge transcode liveness
> is request-observed, not byte-observed). GAP-10 and GAP-11 in particular mean the
> previous blanket claim that enforcement holds "on every serve surface" was **wrong**.
>
> **Update 2026-07-30 — serve-path batch landed.** **GAP-10, GAP-11, GAP-12 and
> GAP-13 are RESOLVED**; each is scored inline below. GAP-12 was pulled forward from
> the revocation batch because it makes the GAP-10 fix inert (see its entry).
>
> **Update 2026-07-30 — tracker-lifecycle batch landed.** **GAP-15 is RESOLVED**
> (edge visibility is now separate from served liveness, and only bytes written from a
> 200/206 upstream response advance `LastServedAt`). Also closed in that batch: the
> overlapping-request defect that let one finishing request delete another live
> request's record, the async-tracking race that could leave a permanently
> non-expiring ghost session, and the protocol-v3 logical-vs-transport identity split
> that counted one stream twice — all three were sources of a **wrong over-cap
> count**, which is why they precede the revocation batch.
>
> **Update 2026-07-31 — liveness/replica batch landed.** **GAP-14 is RESOLVED**:
> download-class pours that cannot be monitored are refused (429/503 + `Retry-After`)
> instead of served blind, and a per-user concurrent-transfer cap
> (`playback.max_user_concurrent_transfers`, default 24) makes registry saturation
> unreachable by one actor. Decision **A5** landed with it, so the client-progress
> caveat below is now closed. Decision **A6** landed too: integrated sessions are
> published to the shared `silo:sessions:` picture under a per-process instance id and
> one Redis-elected evaluator runs each pass, so replicas are no longer mutually blind
> — but **synchronous admission is still per-process**, so cross-replica excess is
> trimmed asynchronously rather than refused at the door.
>
> Monitoring projection is **not** uniformly async, and the claim has been corrected
> rather than the code rushed: the first Redis write per session is synchronous, later
> updates ride the refresh tick. An ordered, lifecycle-aware projection queue is
> deliberately deferred rather than shipping a lossy or reorderable one — a naive
> fire-and-forget projection is what caused the ghost-session defect above.
>
> One claim elsewhere in this doc was **not true** and is now resolved: monitoring
> genuinely does "never trust client progress" as of the liveness batch — the
> `LastActivityAt` fallback is gone from the projection *and* from reaping, with the
> single deliberate exception that a **paused** session holding an open, ping-checked
> realtime/WebSocket connection is exempt from reaping (an observed connection, not a
> reported position; it preserves the issue #243 fix). Do not restore the old
> fallback.
>
> Note also that every "cut" in this document means **cut within ~5s**, not
> immediately: the in-flight watcher polls on a 5s interval.
## Dimensions
- **Server layouts:** (A) integrated, no Redis · (B) integrated, with Redis ·
(C) multi-node, with Redis (proxy/transcode edge nodes).
- **Playback types:** direct play · remux · transcode (HLS).
- **Routes:** native silo `/api/v1` · jellycompat (`:8096`) ·
Audiobookshelf-compatible (`/abs`, `/api`, and public feed/session paths).
## Key runtime facts (why cells collapse)
- **Integrated (A, B) serves bytes locally.** Node selection returns an empty
plan when no edge nodes exist, and both native and jellycompat fall through to
in-process serving (`nodepool/planner.go:248`, `jellycompat/handlers_playback.go:306`
— the `proxyNode == nil` guard in `buildProxyRedirectURL`). A vs B differ only
in *revocation propagation* (memory-only vs Redis) and *monitor source*
(FuncSource vs MultiSource) — the **serve + enforcement points are identical**.
So A ≡ B for this matrix; treated as one column "Integrated".
- **Multi-node (C) serves bytes on edges.** Native playback hands the client a
proxy-node URL at session start (`playback.go:1575`); jellycompat 302-redirects
to a proxy node (`streams.go:120`). Edge serving = `internal/proxy` + `internal/transcodenode`.
- **jellycompat shares the SessionManager.** A jellycompat play starts a real
`playback.SessionManager` session (`streams.go:1127`), so the monitor/enforcer
see it exactly like native. The compat `PlaybackSessionStore` is separate
bookkeeping (PlaySessionId → recipe), not liveness/enforcement.
- **Local transcode fallback:** in multi-node, if no transcode node is available,
jellycompat falls back to LOCAL transcode (`streams.go:277`,
`LocalTranscodeFallbackAllowed`) — i.e. the "Integrated jellycompat" serve path
can also occur under layout C.
## The matrix
Legend: ✅ covered · ⚠️ covered with caveat · ❌ gap.
### Serving handler (where the bytes come from)
| Route | Type | Integrated (A/B) | Multi-node (C) |
|---|---|---|---|
| native | direct | `StreamHandler.HandleStream` → `ServeDirectPlay` (`stream.go:141`) | proxy `handleDirectPlay` (`proxy/server.go:171`) |
| native | remux | `HandleStream` → `ServeRemux` (`stream.go:157`) | proxy `handleRemux` |
| native | transcode | `HandleGetTranscodeSegment` local → `ServeFile` (`playback.go:2860`) | proxy → transcode node `handleSegment` |
| jellycompat | direct | `HandleVideoStream` → `ServeDirectPlay` (`streams.go:153`) | 302 → proxy `handleDirectPlay` |
| jellycompat | remux | `HandleVideoStream` → `ServeRemux` (`streams.go:151`) | 302 → proxy `handleRemux` |
| jellycompat | transcode | `HandleHLSSegment` → `ServeFile` (`streams.go:543`) | 302 → proxy → transcode node |
| ABS | authenticated file/download | `handleFileStream` → `ServeDirectPlay` | same integrated handler |
| ABS | public playback track | `handlePublicTrack` → `ServeDirectPlay` | same integrated handler |
| ABS | public RSS feed file | `handlePublicFeedFile` → `ServeFile` | same integrated handler |
| ebook | original file | `serveEbookInline` → `ServeContent` (`ebook_reader.go:626`) | same integrated handler |
| ebook | converted EPUB | `serveConvertedEpub` → `ServeContent` (`ebook_convert_serve.go:160`) | same integrated handler |
### Monitoring (server-observed existence, reported to central)
The monitoring model separates **existence** (must be server-observed, never
gated by a client report — the hidden-stream defense) from **timing** (client
progress is fine, especially for native). A disguised re-streamer that pulls
bytes but withholds/falsifies progress must still be counted.
| Route | Type | Integrated (A/B) — existence signal | Multi-node (C) — existence signal |
|---|---|---|---|
| native | direct | ✅ transport-count shield for the whole pour (`stream.go:136`) | ✅ edge tracker, byte-observed (`sessionByteWriter`) |
| native | remux | ✅ transport-count shield (`stream.go:146`) | ✅ edge tracker, byte-observed |
| native | transcode | ✅ per-segment transport marker around `ServeFile` (`playback.go:2860`) | ✅ edge tracker (Touch + AddBytes/segment) |
| jellycompat | direct | ✅ transport-count shield (`streams.go:129`) | ✅ edge tracker, owner+route carried |
| jellycompat | remux | ✅ transport-count shield | ✅ edge tracker, owner+route carried |
| jellycompat | transcode | ✅ per-segment transport marker around `ServeFile` (`streams.go:543`) | ✅ edge tracker, owner+route carried |
| ABS | authenticated file/download | ✅ separate in-memory transfer record; cap-exempt | same process only |
| ABS | public playback track | ✅ native-session transport marker + metered bytes | same |
| ABS | public RSS feed file | ✅ separate in-memory transfer record; cap-exempt | same process only |
| ebook | original / converted EPUB | ✅ transfer record + meter (cap-exempt per A4) | same |
- **GAP-11 — RESOLVED (serve-path batch).** The
`/api/v1/ebooks/{content_id}/files/{file_id}/read` route group applied only
`apimw.RequireProfile`, and both serve paths wrapped in `RollingDeadlineWriter`
alone: no metered writer, no transfer record, no `Refuse`, no `WatchAndCut`. Not a
text-file-sized path — `ebookReaderFormat` accepts `epub, pdf, mobi, azw, azw3, cbz,
cbr, fb2, fbz`, and CBZ/CBR archives and scanned PDFs routinely run 100 MB–1 GB+, so
a revoked user kept pulling the whole comic library at full speed while the admin view
showed nothing.
Now metered, registered in the transfer registry, refused on entry and cut in flight,
following the ABS file-handler idiom. **Cap-exempt per decision A4** — admission is
untouched and reading consumes no video stream slot.
⚠️ **Correction to the earlier follow-up guidance, which said to "reuse
`guardRevocationCut`" here — do not.** That wrapper keys on a `session_id` URL param
this route does not have (it would pass `""`) and takes identity from
`streamRequestIdentity`, which looks for a `?st=` stream token this route never
carries. It compiles and silently guards nothing. The identity has to come from the
profile/auth context instead.
- **GAP-13 — RESOLVED (serve-path batch).** `streammonitor.mergeStreams` picked the
record with the later `LastServedAt` *wholesale* and backfilled only
`UserID`/`ProfileID`/`MediaFileID`, `Route`, `ClientIP`, `ClientName`, `HWAccel` and
`Position` — **`BytesServed` was not merged**, so when the central record was fresher
a stream whose bytes were all poured at an edge reported `0`. Now merged as a **max,
not a sum**: the two records are two observers of one pour, so summing would
double-count. `DedupeSessionInfos` had the identical hole and feeds the admin view;
fixed there too.
- **GAP-14 — RESOLVED (liveness/replica batch).** The transfer registry is capped at
`defaultMaxEntries = 10_000` (`transfers/registry.go`). Past that, `Begin` returned
`ErrRegistryFull` and every call site logged at **Debug** and served anyway, with
byte updates for the unregistered pour discarded. Since there is no connection cap
anywhere (abuse matrix E28) and the compat surface is unthrottled (E25/E27), an
attacker could hold the registry at its ceiling and blind download-class monitoring
for every other user.
**Decision A7 shipped: fail closed, plus a per-user cap.** A per-user concurrent
cap (`playback.max_user_concurrent_transfers`, default 24, checked *before* the
global limit) makes saturation unreachable by one actor, and a pour that cannot be
registered is now refused — 429 `transfer_limit_exceeded` or 503
`monitoring_unavailable`, both with `Retry-After` — rather than served unobserved.
Note the site count: there are **five** call sites, not the four previously listed
here; `api/handlers/ebook_reader.go` was missing. Saturation is warned at the
registry (rate-limited per user, carrying route and user id) rather than once per
rejected request, which an actor parked at its cap could turn into log
amplification.
- **Existence is server-observed on every cell** *except the ebook rows above*.
Integrated: a session is
unreapable while `activeTransportCount > 0`, and every byte-serving path now
holds that marker — direct/remux for the whole pour, transcode for each segment
serve (the segment markers were added to close a hidden-stream hole where a slow
single-segment drain past the 45s grace could be reaped mid-serve; the compat
HLS path previously refreshed liveness *only* from the client's progress POST,
a direct violation, now also transport-marked). Edge: a Redis record exists for
the whole connection (direct/remux) or while segments are pulled (transcode),
advanced only by real bytes. **No path requires a client progress report to stay
visible or counted.**
- **Timing is server-observed; the client is trusted only for position.**
`Session.LastServedAt` is deliberately distinct from `LastActivityAt` and is
advanced only by server-observed bytes or transport begin/end. As of decision A5
client progress feeds **neither** the enforcer's victim ordering **nor** reaping:
idleness is measured from `LastServedAt`, falling back only to `StartedAt` behind a
bounded never-served grace, and a never-served session projects an *empty*
`LastServedAt` so it sorts as the stalest over-cap victim. The one deliberate
exception is a **paused** session holding an open, ping-checked realtime/WebSocket
connection, which stays exempt from reaping — an observed connection, not a reported
position (issue #243). Integrated `BytesServed` and
`LastServedAt` are mapped alongside the edge equivalents. On the central side this
holds exactly: `Session.LastServedAt` is written only by `BeginTransport`,
`EndTransport` and `AddServedBytes` (`playback/session.go:1132,1151,1169`) — no
progress-report path touches it.
**GAP-15 — RESOLVED.** The proxy now creates transcode visibility separately
from served liveness. Only bytes actually written from a 200/206 upstream
response advance `LastServedAt`; zero-byte responses, upstream failures, and
non-success error bodies do not.
- Report-to-central: **C** = Redis-backed (edge writes `silo:sessions:*`,
`streammonitor.RedisSource` reads). The first projection write for a session is
synchronous so visibility is established before the request returns; later
liveness and byte updates are asynchronous on the refresh tick. Consequently,
a slow Redis adds latency to the first request of a stream. An ordered,
lifecycle-aware projection queue is deliberately deferred rather than making
this path lossy or reorderable. **A/B** = in-process SessionManager IS central;
read via `streammonitor.FuncSource`. MultiSource unions both, deduped by
canonical logical session identity when available.
- Owner + attribution: the transcode node's start record now carries owner + route
+ client (threaded via `TranscodeStartRequest`), and `streammonitor.mergeStreams`
additionally backfills any missing owner/route/client from another record for the
same session — so an ownerless-but-freshest node record can never bucket a stream
under user 0 (which the enforcer skips) or drop route/client from the view.
### Monitoring data model (what central sees per stream)
First-class monitoring goal: see **all** active streams with enough to act on.
Captured per live stream (`streammonitor.LiveStream` / `nodesessions.SessionInfo`):
| Field | Source | Notes |
|---|---|---|
| existence, session id | both | primary; server-observed |
| user id, profile id | token claims / Session | primary; owner attribution |
| media file id | claims / Session | primary; human title is a display-time lookup (follow-up) |
| play method (direct/remux/transcode) | `Type` | primary |
| **route (native/jellycompat)** | `Route` — `Session.Origin` (integrated) or token `Origin` claim (edge) | primary; native and jellycompat share the SessionManager and `Type`, so route is a distinct field seeded at the two `WithClientInfo` sites and carried to edges in the token |
| node serving | NodeName/NodeURL | integrated stamps the local host |
| client ip / client name | `Session.ClientIP/ClientName` (integrated) or edge request + `ClientName` claim | secondary. **Not usable as a re-streaming fingerprint as-is:** it is one value per session, stamped at session creation (integrated) or overwritten per request (edge), so concurrent viewers of one session collapse to a single address. A restream heuristic needs per-request observation at serve time — see abuse-matrix correction #9 |
| position | `Session.Position` | secondary timing |
| bytes served | edge `AddBytes` / integrated `SessionMeteredWriter` | server-observed throughput signal |
| hw/sw, resolution, codecs | Session / node record | secondary |
- **Admin visibility:** `HandleListSessions` unions the Redis edge records with the
in-process integrated sessions (`LiveLocalSessions`), so a single-node integrated
deployment is no longer blind (previously Redis-only). The union is deduped by
session id (`streammonitor.DedupeSessionInfos`, mirroring `mergeStreams`) so a
stream tracked by both the central manager and the edge serving it shows as ONE
row — matching the enforcer's count. A `node_id` filter targets
an edge, so integrated sessions appear only in the unfiltered listing.
- **Download visibility is a separate sibling collection.** The same unfiltered
admin response includes `transfers`, sourced from a bounded process-local
registry. Transfer rows contain a unique per-pour id, optional download-row
correlation id, owner/profile, media file, route, client metadata, timestamps,
and server-observed bytes. A `node_id` filter excludes them because they belong
to this API process, not an edge. The byte timestamp advances in coarse metered
chunks (1 MiB or final close), and multi-replica deployments see only the
registry of the process answering the request.
- **Downloads are an intentional exemption — with asymmetries.** `/downloads/*`
(native), compat `/Items/{id}/Download`, and ABS authenticated file/download
and RSS feed serves transfer full media without a live-stream session and do NOT
count against the live-stream cap. They now create separate, in-memory transfer
records while a pour is active. Native download quotas run only when a download
row is created; neither `ServeDownload` nor `ServeDirect` gates a pour, and
compat/ABS routes have no shared download quota. All routes arm the shared
`WatchAndCut`, and the same hook deletes every compat login so reconnects need
re-auth. **Arming it was previously not the same as it working:** on ABS routes the
cut could not reach the socket at all (GAP-10) and on the native download routes it
was undone by the rolling write deadline (GAP-12). Both are now fixed, so "a per-user
stream revocation cuts an in-flight download pour" holds on every one of these routes
— within one ~5s watch tick.
### Kill switch (revocation enforced on the serve path)
| Route | Type | Integrated (A/B) | Multi-node (C) |
|---|---|---|---|
| native | direct | ✅ `guardRevocationCut` (Refuse + in-flight cut†) | ✅ `verifyToken` + `cutOnRevocation` (in-flight cut) |
| native | remux | ✅ `guardRevocationCut` (Refuse + cut†) | ✅ verifyToken + cutOnRevocation |
| native | transcode | ✅ `guardRevocation` (refused within one segment) | ✅ proxy verifyToken + node `refuseIfRevoked` + reconstruct guard‡ |
| jellycompat | direct | ✅ `Refuse` + in-flight cut† (GAP-1/3 fixed) | ✅ via proxy; ✅ local fallback guarded |
| jellycompat | remux | ✅ `Refuse` + in-flight cut† | ✅ via proxy; ✅ local fallback guarded |
| jellycompat | transcode | ✅ `Refuse` per segment (GAP-1 fixed) | ✅ via proxy/node; ✅ local fallback guarded |
| ABS | authenticated file/download | ✅ bearer JWT `iat` owner cutoff refuses on entry + in-flight cut† (GAP-10 fixed) | same |
| ABS | public playback track | ✅ native session id + session `StartedAt` owner cutoff + cut† (GAP-10 fixed) | same |
| ABS | public RSS feed file | ✅ feed `CreatedAt` owner cutoff refuses on entry + cut† (GAP-10 fixed) | same |
| ebook | original file / converted EPUB | ✅ `Refuse` on entry + in-flight cut† (GAP-11 fixed) | same |
| native | subtitles | ✅ `guardRevocationCut` (Refuse + cut†) | ✅ verifyToken + cutOnRevocation |
| jellycompat | subtitles | ✅ `Refuse` + cut† (delivery only — extraction is buffered) | same |
† In-flight cut uses `streamrevoke.Store.WatchAndCut` (SetWriteDeadline, checked on
entry then every `Options.WatchInterval` — **5s in production**, so a cut lands within
~5s, not instantly). Works at the edge (the proxy's metered writer implements
`Unwrap`) **and** on the native/compat integrated surfaces: the native
(`statusWriter`, `requestStatusWriter`) and compat (`loggingResponseWriter`,
`compatImageProxyTagResponseWriter`, `debugResponseWriter`) middleware writers
implement `Unwrap()`, so `http.NewResponseController` reaches the socket instead of
no-oping (see GAP-3). chi's own `middleware.Compress` wrapper also implements
`Unwrap()` (chi v5.2.5 `middleware/compress.go:374`), so the globally-mounted
compressor does not break the chain either. ABS's `statusRecorder` now implements it
too (GAP-10), and a **latch** on the request context stops any
`RollingDeadlineWriter` downstream of the cut from re-arming the socket (GAP-12).
**The audit that produced that list originally missed the ABS surface — see GAP-10.**
Any new middleware that wraps a byte-serving route must implement `Unwrap()`, and the check
belongs in a test that exercises the *mounted* router, not the handler in isolation.
**GAP-10 — RESOLVED (serve-path batch).** Every ABS route is wrapped by `accessLog`
(`audiobooks/abs/handler.go:334`), whose `statusRecorder`
(`audiobooks/abs/access_log.go`) implemented `Write`, `WriteHeader`, `Hijack` and
`Flush` but **not** `Unwrap()`. The chain `SessionMeteredWriter → statusRecorder`
dead-ended, `http.NewResponseController(...).SetWriteDeadline` returned
`ErrNotSupported`, and `WatchAndCut` discarded that error and silently stopped
watching — a multi-GB ABS pour started before a `RevokeUser` ran to completion.
Fixed by adding `Unwrap()`. `WatchAndCut` now **logs** a failed `SetWriteDeadline`
(once per watcher) instead of discarding it, so the next wrapper of this shape is
loud rather than invisible.
The old test passed throughout because `audiobooks/abs/revocation_test.go` called the
handlers directly and never saw the middleware. The replacement drives the **mounted**
router over a **real socket**; it and the compile-time `Unwrap` assertion were both
confirmed to fail when `Unwrap()` is removed again, so this cannot regress silently.
**GAP-12 — RESOLVED (serve-path batch).** Was scoped to Batch 3, but pulled forward
because it makes the GAP-10 fix *inert*: adding `Unwrap()` is what makes
`SetWriteDeadline` start succeeding, which is also what makes
`RollingDeadlineWriter.bump()` start succeeding — and `bump()` pushed the deadline
back out to `now + StallWindow` (180s) once `bumpStep` (15s) had elapsed, plus once
more from its own constructor. Fixing GAP-10 alone would have left the ABS kill switch
broken, just differently.
The obvious fix does not work and was rejected: the rolling writer is constructed
*inside* `ServeDirectPlay`/`ServeRemux` and **wraps** the writer `WatchAndCut` holds,
so it sits *above* the watcher. `Unwrap()` walks toward the socket, so the watcher can
never reach it by writer introspection. The cut has to travel by a side channel.
Fixed with `httpstream.CutLatch`, carried on the request context and consulted by
`bump()`. Once latched the writer never extends the deadline again. `bump()` re-checks
the latch *after* setting a future deadline so a concurrent cut cannot be lost to the
check/set race, and `WatchAndCut` keeps re-applying the deadline each tick rather than
returning after the first cut, as belt-and-braces for any topology the latch misses.
**Known bound, by design:** the watcher polls, so a revoked pour keeps delivering for
up to one watch interval — **5s in production** — before the cut lands. On a fast link
that is a meaningful amount of data. Read every "cut" claim below as "cut within ~5s",
not "cut immediately". `Options.WatchInterval` makes the interval injectable so
real-socket tests do not have to wait it out.
‡ The transcode node's serve-path check is session-only (`refuseIfRevoked` passes
`userID = 0`), so a per-*user* kill is enforced by the fronting proxy's `verifyToken`
(which passes `claims.UserID`), not at the node's segment serve. The node's
reconstruct guard *does* use the real `claims.UserID`, so a rebuild-after-restart of
a user-killed session is blocked.
### Restart durability (kill survives a process restart / Redis loss)
This axis exists because the branch sits on top of PR #174 (restart-resilient
playback): a session killed before a restart is **reconstructed** afterward from a
durable recipe card, so if the kill list does not also survive the restart the
reconstructed stream is silently re-served. The kill list must be at least as
durable as the thing it kills.
| Deployment | Session survives restart (PR #174) | Kill survives restart | Kill survives Redis flush |
|---|---|---|---|
| A — integrated, no Redis | ✅ recipe card (PG) | ✅ durable PG mirror | ✅ (never used Redis) |
| B — integrated, with Redis | ✅ | ✅ durable PG mirror (+ Redis warm) | ✅ durable PG mirror |
| C — multi-node, with Redis | ✅ | ✅ central durable PG mirror; edges re-warm from Redis | ✅ central re-warms edges from PG→Redis on next write/poll |
\* Steady-state guarantee. Transient boot caveat: if the durable warm exhausts its
bounded retry **and** Redis is empty, the kill list is empty until the first poll
tick (≤60s). This fails *open* by design — see the "Warm is async-tolerant" bullet
below.
- **Hot path unchanged.** `IsRevoked` is still a pure in-memory map read. Postgres
is touched only on write (`Upsert`), on warm/reconcile (`List` at
`StartSync` and on the poll tick), and on trim (`Prune`) — never per request.
- **Central-side only.** The durable mirror is wired into the integrated/api
`streamrevoke.Store` (`cmd/silo/main.go`), not the edge/proxy/transcode nodes,
which have no app DB and enforce via Redis pub/sub + poll.
- **Bounded growth.** Rows are keyed by `(kind, id)`, so the async enforcer
re-revoking every pass UPSERTs one row rather than accumulating; expired rows are
physically reclaimed by `Prune` on the poll tick. `List` filters active rows
with `unrevoked_at IS NULL` and returns live tombstones separately.
- **Expiry and cutoff merge independently.** Both the in-memory `applyLocal` and
durable `Upsert` keep the later expiry while advancing `RevokedAt` (and its
reason) to the later cutoff. A newer user cutoff with a shorter requested
horizon therefore cannot be suppressed by an older long-lived record.
- **Warm is async-tolerant.** By product decision, a just-reconstructed revoked
stream may serve a few seconds before the durable warm/next Refuse tick cuts it;
no blocking startup ordering is required. Edge caveat: if the boot durable warm
exhausts its bounded retry **and** Redis is empty, the kill list is empty until
the first poll tick (≤60s). This fails *open* by design (a kill switch cannot
fail closed without blocking all playback when the DB is briefly unavailable).
- **Edge Redis-outage fails open.** Edge nodes (proxy/transcode) have no durable
store and learn *new* kills only from Redis pub/sub + SCAN. While Redis is
unreachable, already-cached kills persist but a kill issued *during* the outage
does not reach the edge until Redis recovers — enforcement fails open there.
- **Kills are operator-visible and undoable.** Admins can list, create, and
remove entries with `GET/POST /api/v1/admin/streams/revocations` and
`DELETE /api/v1/admin/streams/revocations/{kind}/{id}`. `Unrevoke` removes the
local and Redis copies, durably changes the Postgres row into a bounded
tombstone, and publishes an unrevocation event. The tombstone survives restart
and prevents a delayed reconcile or stale replica from resurrecting the removed
kill, while a later legitimate revoke clears it. If unrevocation publish
fails, other processes retain the kill until expiry: the residual failure is
safe (dead rather than unexpectedly live) and is reported as a warning.
- **Durable self-heal compares expiry.** The maintenance pass re-upserts a local
entry when the durable copy is missing or expires earlier, repairing a failed
longer mirror instead of treating mere row presence as healthy.
## Findings
> GAP-1..GAP-3 all shipped in the kill-switch commit (see the Status banner for
> how commits are referenced by role rather than SHA).
**GAP-1 — RESOLVED.** jellycompat LOCAL serving now consults the
shared `Store.Refuse` in `HandleVideoStream` (direct/remux) and `HandleHLSSegment`
(per segment), closing the integrated / local-transcode-fallback hole.
**GAP-2 — RESOLVED.** `buildProxyRedirectURL` now stamps
`uid`/`pid`/`mfid` onto the jellycompat proxy-redirect token (looked up from the
upstream SessionManager session), so edge monitoring/kill attribution matches
native. `AuthUserID = 0` no longer occurs for jellycompat.
**GAP-3 — RESOLVED.** The in-flight cut is shared as
`streamrevoke.Store.WatchAndCut` and applied to native `/stream`
(`guardRevocationCut`) and jellycompat `HandleVideoStream`. The original caveat —
`SetWriteDeadline` might not reach the socket through the native/compat middleware
chain — was **confirmed** as a guaranteed integrated no-op (four middleware writers
wrapped `w` without `Unwrap()`) and then fixed by adding `Unwrap()` to
`statusWriter`, `requestStatusWriter`, `loggingResponseWriter`,
`compatImageProxyTagResponseWriter`, and `debugResponseWriter`. The cut now reaches
the socket in integrated mode; if it ever no-ops again the stream still stops on
its next request via `Refuse` (no regression). See VERIFY-1.
**GAP-4 — RESOLVED.** The kill list did not survive a process restart or a Redis
flush, but PR #174's restart-resilient playback *does* reconstruct the session — so
a stream revoked before a restart was silently re-served afterward. This was
universal in deployment A (integrated, no Redis: the kill list was pure RAM) and
occurred on any Redis loss in B/C. Fixed by wiring a concrete Postgres
`DurableStore` (`internal/streamrevoke/durable_postgres.go`, table
`stream_revocations`) into the central `streamrevoke.Store`: `Revoke` mirrors to
Postgres, `StartSync` warms from it on boot, and the poll tick re-warms (heals a
Redis flush) and `Prune`s expired rows. The `defaultTTL` (24h) is held `>=` the
recipe-card `MaxTokenTTL` (24h) as an invariant so a kill cannot expire before its
session can be reconstructed, and expiry is **monotonic** on every write (in-memory
`applyLocal` + durable `Upsert` `GREATEST`). The async enforcer uses a
revoke-if-absent write with a default TTL derived from `playback.MaxTokenTTL`, so
repeated evaluation neither reopens the token after five minutes nor slides a
false positive forever. Hot path
(`IsRevoked`) stays an in-memory read.
**GAP-5 — RESOLVED (post-review).** User-kind revocations were a blanket ban:
any admin user edit (via `OnUserSessionsRevoked`) 403'd that user's playback for
24h even after re-login, unrevocably. Fixed with cutoff semantics — see
"user-ID kills" above.
**GAP-6 — RESOLVED (post-review).** Compat coverage holes: `HandleMasterManifest`
/ `HandleHLSManifest` had no revocation check and kept (or, post-restart,
re-spawned) a killed session's ffmpeg via the ensure path; `HandleVideoStream`
checked revocation only after `ensureUpstreamPlayback`, which can replace an
unreconstructable killed session with a fresh id that passed the check (kill
dodged by re-hitting the URL); `HandleSubtitleStream` ignored kills entirely.
All four now `Refuse` up front.
**GAP-7 — RESOLVED (post-review).** Admin terminate 404'd before revoking when
the session was absent from the central in-memory manager — exactly the
edge-served / post-restart / progress-withholding streams the kill switch
exists for (the admin list showed them via the Redis union; terminate couldn't
touch them). Terminate now writes the revocation FIRST, keyed on the session id
alone, and answers `202 {status: "revoked"}` when there is no local session for
the cooperative command.
**GAP-8 — RESOLVED (post-review).** The admin session list double-counted every
edge-served stream (central manager row + edge Redis row, no dedupe), unlike
the enforcer's merged picture. Now deduped by session id with owner/attribution
carry-forward (`streammonitor.DedupeSessionInfos`).
**GAP-9 — RESOLVED (post-review).** The "restore sendfile" commit was dead code
end-to-end: every stream route runs inside `meterEgress`, and
`meteredResponseWriter` hid `io.ReaderFrom`, so `sessionByteWriter.ReadFrom`'s
fast path never fired and all direct-play/remux bytes went through a userspace
copy. `meteredResponseWriter` now forwards `ReadFrom` (metering the returned
total); a chain test locks the full production writer stack onto the sendfile
path. Related tracker fixes in the same pass: `Touch`/`Track` preserve the
first-seen `StartedAt` (was reset per segment/range request, corrupting the
enforcer's tie-break and the admin start time), `AddBytes` no longer recreates
entries for pruned sessions (slow permanent leak), the proxy→node segment pour
attributes bytes incrementally (a slow drain can't go invisible mid-segment or
post bytes to a dead record), and the transcode node marks serve activity so
its record's `LastServedAt` is no longer frozen at start time.
**GAP-3 (original, superseded) — no in-flight cut for native/compat LOCAL direct-play.**
The original finding: `guardRevocation` refused the next request/reconnect but could
not hang up an in-flight `ServeFile` on the central process for a single long GET.
Now closed — see GAP-3 above (shared `WatchAndCut` + middleware `Unwrap()`). Even
if the cut degrades, admin terminate also fires the realtime command + session stop,
so a killed direct-play stops on its next request at worst.
## Open verification items (check before prod)
- [x] **VERIFY-1 — in-flight cut reaches the socket on native + jellycompat — RESOLVED.**
`streamrevoke.Store.WatchAndCut` cuts a revoked long-GET direct-play/remux by
setting a zero write deadline via `http.NewResponseController(w)`, which walks the
writer chain via `Unwrap()`. This was **confirmed broken** in integrated mode:
`statusWriter`/`requestStatusWriter` (native) and `loggingResponseWriter`/
`compatImageProxyTagResponseWriter`/`debugResponseWriter` (compat) wrapped `w`
without `Unwrap()`, so `SetWriteDeadline` returned `ErrNotSupported` and the cut
no-oped. Fixed by adding `Unwrap() http.ResponseWriter` to all five (pattern from
`activitylog/middleware.go:186`). A live smoke test is still worthwhile: start a
native and a jellycompat single-long-GET direct-play, admin-terminate it, confirm
the connection drops within ~5s rather than only on the next request.
- [x] **VERIFY-2 — download routes vs the stream cap — RESOLVED (documented exemption).**
Native, compat, and ABS download-class routes remain intentionally exempt from
the live-stream cap but are visible in the sibling, process-local `transfers`
array. Native concurrency/period checks happen only at row creation, not at
serve time. A per-user revocation is a cutoff: it refuses/cuts older pours but
deliberately allows a new authenticated pour started after the cutoff.
- [x] **VERIFY-4 — transcode buffer-ahead evasion — RESOLVED.** Unpaused
transcodes use a bounded 10-minute integrated grace measured from the same
activity resolution advanced by server-observed bytes/transports; paused
transcodes retain the longer 30-minute paused grace. Edge transcode records
use a 180-second idle window consistently in `ActiveCount`, `Snapshot`, and
refresh. This deliberately avoids a local ffmpeg liveness probe, which cannot
represent offloaded or completed copy-mode transcodes.
- [ ] **VERIFY-3 — multi-replica central enforcement.** The async enforcer runs on
every integrated/api process. Two *integrated* replicas behind a load balancer
each see only their own in-process `FuncSource` (integrated mode writes no
`silo:sessions:*` records), so a user split across both can exceed `max_streams`
undetected; multiple *api* replicas each run an independent enforcer and revoke
the same victims (idempotent, but redundant write load). A single-leader election
for the enforcer, or having integrated nodes publish their local sessions to the
shared Redis picture, would close this.
## Design assessment (shared vs duplicated code)
**Kill DECISION is well-centralized — keep it.** All revocation state and the
`IsRevoked(sessionID, userID)` check live in one place (`internal/streamrevoke`).
Session kills and user kills use the SAME store and the SAME check — `RevokeSession`
and `RevokeUser` write into one `items` map; `IsRevoked` tests both keys. No
duplication at the decision layer.
**Kill ENFORCEMENT — DONE.** The "extract sid/uid → refuse (403)"
step is now a single shared method `streamrevoke.Store.Refuse(w, sessionID, userID)
bool`. All four serve surfaces call it (proxy `verifyToken`, transcode node
`refuseIfRevoked`, native `guardRevocation`, jellycompat serve handlers); each only
owns its token extraction (path `{token}`, query `st`, or compat `PlaySessionId`).
The in-flight connection-cut is the shared `WatchAndCut` (SetWriteDeadline), used by
both the edge (`cutOnRevocation`) and the native/compat long-pour paths. ONE section
per concern, as intended.
**Monitoring WRITE side is two implementations by necessity — leave it.** Edges
have no SessionManager; the integrated process has no tracker. `nodesessions.Tracker`
(edge) and `SessionManager` (integrated) are different runtimes, not duplicated
logic, and they already converge at the single read layer (`streammonitor`,
unioned by MultiSource). Forcing one implementation would be worse.
**user-ID kills — plumbing check (as asked).** Two distinct layers, correctly
separate:
- *Auth/login revocation* (pre-existing): `OnUserSessionsRevoked` revokes native
auth sessions (`auth.SessionRepository.RevokeAllByUser`) and drops compat login
tokens (`SessionStore.DeleteByUserID`). This governs "can this user authenticate",
not "stop this byte stream".
- *Stream revocation* (new): `streamRevocation.RevokeUser` writes a `KindUser`
entry into the SAME `streamrevoke` store as session kills. Session-level and
user-level STREAM kills already share all plumbing (one store, one `IsRevoked`).
- **A user kill is a CUTOFF, not a ban (GAP-5 fix).** `IsRevoked` matches a
`KindUser` entry only when the stream's credential predates the revocation:
the stream token's `iat` on token-bearing surfaces (edge proxy, transcode
node, native `?st=`), the access-token `iat` on native authenticated routes,
and the compat login's stable `CreatedAt` on normal Jellyfin sessions. An
unknown (zero) credential time never matches (fail open). API keys have no
issue time, so a user cutoff cannot cut an API-key-owned pour; this is an
accepted, logged limitation. Per-login logout cuts remain out of scope until
stream credentials carry per-login identity. This is what makes it safe for
`OnUserSessionsRevoked` — which fires
on ANY admin edit of password/role/enabled/permissions/quality — to also
write a stream kill: without the cutoff, a routine permission tweak 403'd
the user's playback for the full 24h TTL after re-login unless an operator
explicitly removed the kill.
- These two layers should NOT be merged — conflating "may log in" with "this
stream must die" would couple auth and playback. They are chained in the same
hook, which is the right seam.
## Recommended follow-up order
GAP-1, GAP-2, GAP-3, and GAP-4 are all shipped (see Findings). Already-shipped
hardening not to re-do: `Revoke` propagation uses `context.WithoutCancel`, so an
aborted admin request can no longer strand a kill in central memory only.
Remaining work:
1. **Operator config follow-up:** the over-cap lifetime is now validated as
`playback.over_cap_revocation_ttl` and defaults to the reconstructable token
lifetime. It is startup-bound and affects only future revocations after the
required restart: monotonic expiry cannot shorten an existing kill.
Poll/default admin-revocation settings remain separate follow-up work.
2. **Media title enrichment:** resolve `MediaFileID → title` at admin display time
so the view shows *what* is being watched, not just a numeric id (also enables a
distinct-title re-streaming heuristic).
3. **Multi-replica enforcement (VERIFY-3, explicitly deferred):** single-leader election for the async
enforcer, or have integrated nodes publish local sessions to the shared Redis
picture so the cap holds across replicas.
4. **Invariant guard test:** `defaultTTL >= playback.MaxTokenTTL` is coupled only by
a prose comment (streamrevoke can't import playback — import cycle). Add a test
in a package that can import both, so raising `MaxTokenTTL` can't silently
violate it.
5. **Transcode-node ghost records (explicitly deferred):** the node's session-backed `Track` record is
refreshed forever until an explicit stop/cleanup; if central dies or loses the
session without the stop reaching the node, an owner-attributed ghost persists
in Redis indefinitely — permanently +1 in the user's live count (the enforcer's
freshness ordering trims the ghost first, so real streams survive, but the
enforcer re-revokes it every pass and the admin view overcounts). Needs a
node-side idle sweep tied to real serve activity (`MarkServed` now provides the
signal).
6. **Download controls phase 2:** add per-download kill and a standing per-user
download block (a user revocation is only a cutoff and permits newer
credentials/requests). Separately unify native, compat, and ABS under a shared
download quota and rolling volume budget. Also resolve the pre-existing native
completion semantics (`ServeContent` cannot report write failure), correlate
ABS playback sessions with duplicate `abs_file_stream` transfer rows, and make
transfer visibility multi-replica rather than process-local.
-819
View File
@@ -1,819 +0,0 @@
# 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/{sid}/replan (mint token) │
Client │ RequireAuth /playback/start /playback/transcode/start (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,13 +564,12 @@ sequenceDiagram
participant C as Client
participant S as Central server (integrated)
participant PG as Postgres (access)
Note over C,S: start — the plan decides this is a transcode
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
Note over C,S: transcode start
C->>S: POST /playback/transcode/start (RequireAuth)
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: 201 plan.url = /playback/transcode/{sid}/master.m3u8?st=TOKEN
S-->>C: 202 manifestURL = /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)
@@ -600,9 +599,9 @@ sequenceDiagram
participant N as Proxy node
participant T as Transcode node
participant R as Redis
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
C->>S: POST /playback/transcode/start (RequireAuth)
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
S-->>C: plan.url = {N}/stream/transcode/TOKEN/master.m3u8
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
loop manifest + segments
C->>N: GET /stream/transcode/TOKEN/...
N->>N: verifyToken(TOKEN)
+778
View File
@@ -0,0 +1,778 @@
# Stream & API Abuse — Coverage Matrix
> **What this is.** A red-team assessment of how the `feat/sauron-async-enforcer`
> branch (server-observed stream monitoring + revocation kill switch + async
> over-cap enforcer) — layered on the current `main` — reacts to ~29 concrete
> abuse stories. Each story is scored on two axes the branch itself separates:
>
> - **Detection** — does the server *see* the abuse and *label it correctly*
> (right user, right stream, right kind)?
> - **Enforcement** — can the server *stop* it, and *how fast*?
>
> **Companion docs:** the intent lives in
> [`../superpowers/plans/2026-07-04-stream-monitoring-and-kill-switch.md`](../superpowers/plans/2026-07-04-stream-monitoring-and-kill-switch.md);
> the as-built coverage-by-path lives in
> [`playback-paths-monitoring-kill-matrix.md`](playback-paths-monitoring-kill-matrix.md).
> This doc is the *adversarial* companion: it assumes a motivated abuser and asks
> where the design ends.
>
> **Scope note.** The branch is scoped to **concurrent playback-stream abuse**. A
> large fraction of real-world "abuse" (library enumeration load, bulk ripping,
> API request floods, CPU exhaustion, re-broadcasting) lives *outside* that scope.
> Where that is the case this doc says so plainly and points at the subsystem that
> would actually own the defense (`internal/ratelimit`, `internal/downloads`,
> catalog browse, node scheduling) — several of which have real gaps of their own.
## Legend
| Symbol | Detection | Enforcement |
|---|---|---|
| ✅ | Seen and correctly attributed | Stopped (and how fast) |
| ⚠️ | Seen but partial / mislabeled / lossy | Partial, slow, best-effort, or config-dependent |
| ❌ | Invisible to the server | Cannot stop |
Enforcement latency budget for the async path is **~120s** = enforcer tick
(`DefaultInterval = 30s`, `streamenforcer/enforcer.go`) + revocation propagation
(pub/sub push immediate; ≤60s poll safety net, `streamrevoke/store.go`) + in-flight
cut (5s `WatchAndCut` ticker for long pours; next segment for HLS). Synchronous
admission refusal (`ErrTooManyStreams`, `playback/session.go`) is **immediate**.
---
## Corrections the investigations turned up (read first)
The branch docs oversell several things. The stories below are scored against the
**code**, not the plan.
> **Round 2 — 2026-07-29, two-model review (Claude Opus 5 + Codex gpt-5.6-sol).**
> Corrections 4–8 below were found by a second adversarial pass over the rebased
> branch and are **open defects, not doc drift**. They matter because they hit the
> two stated priorities directly: #1 "see all bytes, no invisible streams"
> (corrections 5, 7, 8) and #2 "kill any stream" (corrections 4, 6). Affected rows in
> the summary matrix have been downgraded accordingly.
1. **The async enforcer uses the effective, group-merged limit.** An earlier
audit found it reading raw `users.max_streams`, which is zero ("inherit") for
standard accounts. That is no longer true: its limit function calls
`SessionManager.EffectiveLimits`, the same group-merged policy used by
admission. Standard users therefore receive the Default Group cap instead
of being treated as unlimited.
2. **The re-streaming heuristic does not exist.** The plan describes shipping
`OverCapRule` + a disabled `RestreamHeuristicRule` behind a pluggable `Rule`
interface. In code, `streamenforcer/enforcer.go` is a single hardcoded over-cap
loop — no rule interface, no restream logic, not even a disabled stub. The
fingerprints a heuristic *would* need (`ClientIP`, `ClientName`, `MediaFileID`,
`BytesServed`) are captured in `streammonitor` but nothing consumes them for
detection.
3. **There is no server-wide or per-node transcode/CPU cap.** Only a per-*user*
`max_transcodes` (default 2, `<=0` = unlimited) checked at admission. The one
ffmpeg semaphore (`reconstructSem`, sized to `NumCPU`) guards **only** the
restart-reconstruct path in `transcodenode/server.go`, **not** fresh starts.
> **Update 2026-07-30 — serve-path batch landed.** Corrections **4, 5, 6 and 7 below
> are now FIXED** (GAP-10, GAP-11, GAP-12, GAP-13); they are kept here with their
> original wording because the summary-matrix rows and the follow-up list still
> reference them, and because the *reason* each was invisible to the test suite is the
> durable lesson. Correction **8 (GAP-14) is now also fixed** by the liveness/replica
> batch under decision A7. See the follow-up list at the end for what is left.
>
> **Update 2026-07-30 — tracker-lifecycle batch landed.** GAP-15 is also fixed, along
> with three defects that all produced a **wrong over-cap count**: overlapping edge
> requests deleting a live record, an async-tracking race that could strand a
> permanently non-expiring ghost session, and the protocol-v3 logical-vs-transport
> identity split that counted one stream twice (masked until now because fresh v3
> starts sent no owner at all, so the record landed under user 0, which the enforcer
> skips — silently exempting the stream from the cap). These precede the revocation
> batch on purpose: decision A1 removes the self-healing that limits the damage of a
> miscount, so the count must be trustworthy first.
>
> Both claims this document previously flagged as false are now true, with named
> bounds. The kill switch does "keep a stream dead": since the revocation batch an
> over-cap kill lasts the token's full reconstructable life instead of reopening after
> 5m. Monitoring does "never trust client progress": since the liveness batch the
> `LastActivityAt` fallback is gone from both the projection and reaping — the single
> deliberate exception being that a **paused** session holding an open, ping-checked
> realtime/WebSocket connection is exempt from reaping (an observed connection, not a
> reported position). Still read every "cut" as "cut within ~5s" — the in-flight
> watcher polls on a 5s interval.
4. **The ABS in-flight kill switch does not work.** *(FIXED — `Unwrap()` added; the
replacement test drives the mounted router over a real socket and was confirmed to
fail without the fix.)* Every ABS route is wrapped by
`accessLog` (`audiobooks/abs/handler.go:334`), whose `statusRecorder`
(`audiobooks/abs/access_log.go:83`) implements `Write`, `WriteHeader` and `Hijack`
but **not** `Unwrap()`. `WatchAndCut` walks
`SessionMeteredWriter → statusRecorder` and dead-ends, so
`SetWriteDeadline` returns `ErrNotSupported` — an error `WatchAndCut` discards
(`streamrevoke/store.go:187`). The watcher then silently stops. A multi-GB
audiobook pour started before a `RevokeUser` runs to completion.
`audiobooks/abs/revocation_test.go` calls handlers directly, bypassing the
middleware, so the test suite reports this as working. (GAP-10)
5. **Ebook, comic and PDF serving is invisible and un-killable.** *(FIXED — now
metered, registered in the transfer registry, refused on entry and cut in flight;
cap-exempt per decision A4. Note the fix does **not** reuse `guardRevocationCut` as
originally suggested — that wrapper keys on a `session_id` param and `?st=` token
this route does not carry, so it would have silently guarded nothing.)* The
`/api/v1/ebooks/{content_id}/files/{file_id}/read` group (`api/router.go:2417`)
carries only `apimw.RequireProfile`. Both `serveEbookInline`
(`ebook_reader.go:626`) and `serveConvertedEpub` (`ebook_convert_serve.go:160`)
pour whole files with no meter, no transfer record, no `Refuse` and no
`WatchAndCut`. `ebookReaderFormat` (`ebook_reader.go:675`) accepts `cbz`/`cbr`
comic archives and `pdf` — routinely 100 MB–1 GB+ each. (GAP-11)
6. **The rolling write deadline can erase a revocation cut.** *(FIXED — a `CutLatch`
on the request context stops `bump()` from re-arming a cut socket, and the watcher
keeps re-applying. Pulled forward from the revocation batch because it makes
correction 4's fix inert: adding `Unwrap()` is what makes `SetWriteDeadline` start
working, which is also what makes `bump()` start working.)* `WatchAndCut` sets the
socket deadline to *now* once, then returns. On routes wrapped in
`httpstream.RollingDeadlineWriter` — native managed and direct downloads
(`api/handlers/downloads.go:447`) — `bump()`
(`httpstream/rolling_deadline.go:95`) runs before every write slice and, once the
15s `bumpStep` has elapsed, pushes the deadline back out to `now + 180s`. Nothing
re-arms the watcher. The cut is therefore reliable against a *stalled* pour and
unreliable against a *fast-draining* one — weakest against the ripping case it
exists to stop. (GAP-12)
7. **Merged snapshots discard edge byte totals.** *(FIXED — merged as a max, not a sum,
since the two records are two observers of one pour; `DedupeSessionInfos` had the
same hole and was fixed too.)* `streammonitor.mergeStreams` takes
the record with the later `LastServedAt` wholesale and backfills owner, route,
client, HWAccel and position — but **not `BytesServed`**. When the central record
is fresher, a stream whose bytes were all poured at an edge reports `0` bytes to
the operator. (GAP-13)
8. **Transfer-registry saturation serves unmonitored.** The registry caps at
`defaultMaxEntries = 10_000` (`transfers/registry.go:16`); past that `Begin`
returns `ErrRegistryFull` and all four call sites log at **Debug** and serve
anyway, discarding byte updates (`registry.go:122`). With no connection cap
anywhere (E28) and an unthrottled compat surface (E25/E27), an attacker can pin
the registry at its ceiling and blind download-class monitoring for everyone
else. (GAP-14)
One thing the docs *under*-sell: `main` already ships a real general API rate
limiter (`internal/ratelimit/`), enabled by default — but it is mounted only on
the authenticated native surface + specific auth endpoints, and the entire
Jellyfin-compat surface is unthrottled (see Category E).
---
## Summary matrix
### Category A — Concurrency-cap abuse (the branch's core competency)
| # | Abuse story | Detection | Enforcement | One-line verdict |
|---|---|---|---|---|
| 1 | User opens N+1 concurrent **transcodes** (over cap) | ✅ | ✅ immediate | Admission refuses the N+1 start synchronously |
| 2 | User opens N+1 concurrent **direct-play** streams | ✅ | ✅ immediate | Same admission gate; method-agnostic |
| 3 | **Shared account**, many devices streaming at once | ✅ | ✅ / ⚠️ | Admission caps per-user on one process; cross-replica excess is now seen and trimmed by the enforcer within ~120s (#7) |
| 4 | **Buffer-ahead then pause** fetches >45s to duck the cap | ✅ | ✅ | Unpaused transcodes retain a 10m integrated / 180s edge server-observed grace; paused sessions now age from the last **served byte**, not the last client ping |
| 5 | **Withhold/falsify progress** to hide a stream | ✅ | ✅ | Existence is byte-observed, and since A5 progress no longer confers liveness or reprieves reaping — a progress-only phantom ages out and sorts as the stalest over-cap victim |
| 6 | **Rapid session churn** between enforcer ticks | ⚠️ | ⚠️ | Admission blocks over-cap starts; no rate limit on `/start` |
| 7 | Split streams across **multiple integrated replicas** | ✅ | ⚠️ ~120s | Integrated sessions are published to the shared `silo:sessions:` picture and one elected evaluator trims the excess. **Admission is still per-process**, so the over-cap starts are admitted and then trimmed, not refused |
| 8 | **Standard user** exceeds cap via the edge/multi-node path | ✅ | ✅ ~120s | Enforcer resolves the group-merged effective cap and trims the excess |
### Category B — Admin control / kill switch
| # | Abuse story | Detection | Enforcement | One-line verdict |
|---|---|---|---|---|
| 9 | Admin **terminates** a specific abusive live session | ✅ | ✅ | Terminate writes revocation first, keyed on sid; 202 even if no local session |
| 10 | Admin **bans a user** (revoke all their streams) | ✅ | ✅ | Cutoff refuses every *next* request; in-flight cut now lands on every surface within ~5s (GAP-10/GAP-12 fixed) |
| 11 | Killed stream **reconnects** with the same token | ✅ | ✅ | Revocation outlives token; every reconnect `Refuse`d |
| 12 | Killed stream **survives a server restart** | ✅ | ✅ | Durable Postgres mirror re-warms the kill list; monotonic expiry |
| 13 | Kill issued **during an edge Redis outage** | ⚠️ | ⚠️ | Edge fails **open**: new kills don't reach edge until Redis recovers |
### Category C — Re-streaming / redistribution
| # | Abuse story | Detection | Enforcement | One-line verdict |
|---|---|---|---|---|
| 14 | Re-broadcast **one** Silo stream to many external viewers | ❌ | ❌ | Under-cap = untouched; no restream heuristic exists (correction #2) |
| 15 | **Many** concurrent Silo streams feeding a restream service | ⚠️ | ✅ ~120s | Caught only as raw over-count, not labeled re-streaming |
| 16 | **Mint & hoard** many 24h tokens, fan out later | ❌ | ❌ / ⚠️ | No mint cap, signature-only, stop doesn't revoke; caught only if fan-out becomes over-cap |
### Category D — Ripping / bulk data exfiltration
| # | Abuse story | Detection | Enforcement | One-line verdict |
|---|---|---|---|---|
| 17 | Rip whole library via **native download** endpoints | ✅ | ⚠️ | Pours are always monitored or refused (429/503) since A7, and a per-user concurrent cap bounds one actor; creation-time gate only; **still no volume cap** |
| 18 | Rip via **compat `/Items/{id}/Download`** (Infuse) | ✅ | ⚠️ | Pour is monitored or refused, and the per-user concurrent cap applies; **still no volume quota** |
| 18a | Rip via authenticated **ABS file/download** route | ✅ | ⚠️ | Pour visible, cuttable in flight (GAP-10), and now monitored-or-refused with a per-user concurrent cap (A7); still **no volume quota** |
| 18b | Pull an **ABS public playback track** | ✅ | ✅ | Native session id is monitored/metered; session and owner kills land |
| 18c | Pull a **public ABS RSS feed file** | ✅ | ⚠️ | Active pour visible; owner cutoff uses feed creation time and the in-flight cut now lands (GAP-10 fixed). Closing a feed still does not cut its current pour; that needs a separately designed, collision-safe feed capability revocation id and is deferred. |
| 18d | Rip via the **ebook/comic/PDF reader** route | ✅ | ⚠️ | Metered, transfer-rowed, refused on entry and cut in flight (GAP-11); also fails closed under A7 — this was the fifth `Begin` call site the batch handoff missed. Cap-exempt per A4; still no volume quota |
| 19 | Rip via **sequential direct-play GETs** (one at a time) | ⚠️ | ❌ | Cap counts concurrency, not volume; stays at 1 forever. A7's per-user cap is also concurrency-based and does not touch this |
| 20 | Admin **stops an in-progress rip** mid-transfer | — | ✅ | `WatchAndCut` now lands on every download-class route within ~5s (GAP-10/GAP-12 fixed); admin must still issue the revoke |
### Category E — API / DB load abuse (non-stream; branch is orthogonal)
| # | Abuse story | Detection | Enforcement | One-line verdict |
|---|---|---|---|---|
| 21 | **Infuse pulls the whole library list on every app start** | ❌ | ⚠️ | Per-page capped at 1000; full browse is uncached & unthrottled |
| 22 | **Thundering herd** — many clients cold-cache full enumeration | ❌ | ⚠️ | Latest/hub rails singleflight; general browse does not |
| 23 | Client **pages a 100k+ library as fast as possible** | ❌ | ❌ | Per-page COUNT + rising OFFSET; no page-rate limit, no cache |
| 24 | **Expensive sorted/filtered views** over the whole library | ❌ | ⚠️ | Some sorts index-optimized; `DateCreated`/filters = full top-N + COUNT, uncapped |
| 25 | Badly-coded client **retry storm** (thousands req/s) | ✅ | ⚠️ | Native authed surface throttled (per-IP 120rps); compat & `/auth/refresh` not |
| 26 | Tight-loop polling **`/auth/refresh`** | ✅ | ❌ | No rate-limit middleware on that route |
| 27 | **Credential stuffing** via compat `/Users/AuthenticateByName` | ✅ | ❌ | Compat router mounts no limiter; native login is capped 20/min |
| 28 | Many concurrent **HTTP connections / slowloris** | ❌ | ❌ | No connection cap / `LimitListener`; only Read/Write/Idle timeouts |
| 29 | Spawn many concurrent **transcodes to exhaust CPU** | ⚠️ | ❌ | Per-user `max_transcodes` only; no per-node/server-wide ffmpeg cap |
---
## Detailed stories
### Category A — Concurrency-cap abuse
**A1. N+1 concurrent transcodes (over cap).**
The (N+1)th `StartSession` returns `ErrTooManyStreams` *synchronously* at admission
(`playback/session.go`, `inlineAdmissionErrorLocked`), using the group-merged
effective cap (5 for a standard user). The client never gets a session. The async
enforcer resolves the same effective cap as its redundant backstop.
**Detection ✅ / Enforcement ✅ (immediate).**
**A2. N+1 concurrent direct-play streams.**
Direct-play sessions are `Track`-backed and counted identically at admission — the
`MaxStreams` check is method-agnostic. Same immediate refusal. **✅ / ✅.**
**A3. Shared account, many devices at once.**
All sessions carry the same `AuthUserID`; `streammonitor.ByUser` groups them and
`Client*` fields expose the fan-out. The per-user cap bounds simultaneous streams
to the effective `MaxStreams`. **Caveat:** the cap is per-process (see A7), so
spreading devices across integrated replicas multiplies the allowance.
**Detection ✅ / Enforcement ✅ on one process, ⚠️ across replicas.**
**A4. Buffer-ahead then pause (VERIFY-4 resolved).**
An unpaused transcode now has a bounded 10-minute integrated grace, driven by
server-observed `LastServedAt`, and ephemeral edge transcode records use a
180-second idle window consistently in count, snapshot, and refresh. A genuinely
paused session still receives the longer 30-minute paused grace. This covers
local, completed copy-mode, and offloaded transcodes without probing local ffmpeg.
**Detection ✅ / Enforcement ✅.**
**A5. Withhold/falsify progress reports.**
This *fails* as an evasion. Existence and liveness are **byte-observed**
(`BeginTransport`/`EndTransport` bracket every pour; `AddBytes`/`MarkServed` set
`LastServedAt`), never derived from client progress. Falsifying `Position` only
corrupts a secondary display field and, at worst, changes which of the user's
*own* over-cap sessions `selectVictims` trims first. **Detection ✅ / Enforcement ✅.**
*Two round-2 caveats, both bounded:* (a) at the **edge**, `touchTranscodeSession`
fires before the node is proxied (`proxy/server.go:308,317`), so hammering dead
segment URLs advances `LastServedAt` without serving a byte — liveness there is
request-observed, not byte-observed (GAP-15). Because the enforcer groups by user,
this only lets an attacker choose *which of its own* streams gets trimmed. (b) the
merged snapshot could report `0` bytes for an edge-served stream (GAP-13) — **fixed**;
`BytesServed` is now merged as a max in both `mergeStreams` and `DedupeSessionInfos`,
so the byte signal is sound as well as the existence signal.
**A6. Rapid session churn between ticks.**
No rate limit exists on `/start` or `/transcode/start` (the `ratelimit`
middleware is wired only to auth endpoints). In integrated mode synchronous
admission still refuses over-cap starts instantly, so churn can't exceed the cap
locally. The gap is the multi-node/edge picture, where admission may have run on a
different node and the enforcer only reconciles every ~120s.
**Detection ⚠️ / Enforcement ⚠️ (integrated fast, edge lags).**
**A7. Split across multiple integrated replicas.**
`activeCountLocked` counts only the local `SessionManager` map — there is no
distributed admission counter. Two integrated replicas each admit up to the cap
independently. The enforcer is the only cross-replica backstop, but integrated
replicas write no `silo:sessions:*` records, so a remote enforcer cannot see them.
**Detection ⚠️ / Enforcement ⚠️.** (VERIFY-3.)
**A8. Standard user exceeds the cap on the edge path.**
The enforcer resolves `SessionManager.EffectiveLimits`, so a raw
`users.max_streams = 0` inherits the group cap rather than meaning unlimited.
The edge monitoring picture is grouped by resolved owner and excess victims are
revoked on the async pass. **✅ / ✅ (~120s).**
### Category B — Admin control / kill switch
**B9. Admin terminates a specific session.**
`terminate` writes the revocation **first**, keyed on the session id alone, then
issues the cooperative realtime command; it answers `202 {status:"revoked"}` even
when the session is absent from the central in-memory manager (GAP-7) — exactly the
edge-served / post-restart / progress-withholding streams the kill switch exists
for. **Detection ✅ / Enforcement ✅.**
**B10. Admin bans a user.**
`RevokeUser` writes a `KindUser` entry; `IsRevoked` matches it for any of the
user's sessions whose credential predates the revocation (cutoff semantics, GAP-5),
so in-flight pours are cut (`WatchAndCut`) and reconnects `Refuse`d, while a
later legitimate re-login is not permanently banned. **✅ / ✅.**
**B11. Killed stream reconnects with the same token.**
The revocation entry outlives the token TTL, so every reconnect with the same
credential is refused. "Stays dead." **✅ / ✅.**
**B12. Killed stream survives a restart.**
The durable Postgres mirror (`streamrevoke/durable_postgres.go`, table
`stream_revocations`) is warmed on boot and re-armed on the poll tick. Revocation
expiry and cutoff merge independently, and durable unrevocation tombstones prevent
restart or stale-replica resurrection. This closes GAP-4, where PR #174's
restart-resilient playback would
otherwise reconstruct and re-serve a killed stream. **✅ / ✅.** *Transient boot
caveat:* if the durable warm exhausts its bounded retry **and** Redis is empty, the
kill list is empty until the first poll tick (≤60s) — fails **open** by design.
**B13. Kill issued during an edge Redis outage.**
Edge nodes have no durable store and learn *new* kills only from Redis pub/sub +
SCAN. While Redis is unreachable, already-cached kills persist but a kill issued
*during* the outage does not reach the edge until Redis recovers. **Detection ⚠️ /
Enforcement ⚠️ (fails open at the edge).**
### Category C — Re-streaming / redistribution
**C14. Re-broadcast one Silo stream to many external viewers. — NO DEFENSE.**
The classic Stremio/Plex-share abuse: one account pulls a single stream and a proxy
fans it out to hundreds. It stays at concurrency 1, so admission and the enforcer
never fire, and the re-streaming heuristic that would catch it *does not exist*
(correction #2). **Correction #9 (2026-07-31): the fingerprints are NOT ready to
consume, contrary to what this document and the architecture plan previously said.**
`ClientIP` is a *single* value per session, not a set: integrated mode stamps it once at
session creation and no media request ever updates it
(`internal/api/handlers/stream.go` never touches `ClientIP`), while the edge tracker
overwrites `records[sessionID]` on every `Track`/`EnsureEphemeral`
(`internal/nodesessions/tracker.go`). `mergeStreams` then collapses multi-node records
to one winner and only backfills an IP when the winner has none. So five devices pulling
one session still present as one address, and a polling consumer cannot see fan-out at
all. Building the heuristic requires collecting bounded viewer observations **at
authenticated media-serve time**, not consuming the existing snapshot. Note also that C14
proper — a downstream proxy re-broadcasting one pulled stream — is invisible to any
IP-based signal by construction: Silo sees exactly one address. (The edge's own address
resolution was separately blind behind an ingress and is now fixed — `edgeClientIP`
resolves through the `clientip.trusted_proxies` boundary — but that only makes viewers
distinguishable; it does not create the per-session address *set* the heuristic needs.)
**Detection ❌ / Enforcement ❌.**
**C15. Many concurrent streams feeding a restream service (over cap).**
This *is* caught — but only as a raw over-count, not labeled as re-streaming.
`ByUser` counts sessions per uid; the enforcer revokes victims beyond the limit
within ~120s (and admission refuses new over-cap starts immediately). Relies on
correct owner attribution (jellycompat/edge records must carry the owner or they
bucket under user 0 and are skipped — mitigated by `mergeStreams` owner-adoption).
**Detection ⚠️ (as over-cap, not as restream) / Enforcement ✅ ~120s.**
**C16. Mint & hoard 24h tokens, fan out later.**
Stream tokens are signature-only 24h JWTs (`streamtoken/token.go`) with no `jti`,
no one-time-use, no per-user mint counter. A normal Stop does **not** write a
revocation, so a token for a stopped session stays cryptographically valid for its
full 24h. Hoarding is free and invisible at mint time. Fan-out is caught *only* if
the aggregate live use trips the per-user over-cap enforcer — i.e. a low-concurrency
fan-out (few tokens, each widely re-streamed) collapses back into C14 and evades
everything. **Detection ❌ (at mint) / Enforcement ❌ (unless it becomes over-cap).**
### Category D — Ripping / bulk data exfiltration
**D17. Rip via native download endpoints.**
Download record creation is bounded by `download.max_concurrent_per_user`
(default **3**, `internal/downloads/limiter.go`). Serving and re-fetching an
existing row is not gated. `download.max_per_period` and both bandwidth
knobs default to **0 = unlimited** — and the bandwidth managers are *rate* throttles
(token buckets), never *volume* caps. The concurrency check runs at record
creation, so a `create → complete → create` loop pulls the entire library 3 files
at a time forever. Active pours and server-observed bytes are visible through the
process-local admin `transfers` array, but disappear at completion and are not a
cumulative signal. **No total-volume cap exists anywhere.** **Detection ⚠️ /
Enforcement ⚠️ (creation-time gate only).**
**D18. Rip via compat `/Items/{id}/Download`. — WIDEST HOLE.**
`HandleDownload` (`jellycompat/streams.go`) requires only a compat session and the
per-item library-access filter, then serves the raw file with full Range support.
**No download row, no quota, no bandwidth throttle, and no byte cap.** An
Infuse-style client can GET/Range every original file back-to-back. The active
pour and served bytes are visible in the process-local admin transfer registry,
but there is no durable history or automated consumer. **Detection ⚠️ /
Enforcement ❌.**
**D18d. Rip via the ebook/comic/PDF reader route. — SECOND-WIDEST HOLE.**
`GET /api/v1/ebooks/{content_id}/files/{file_id}/read` (`api/router.go:2420`) is
guarded only by `apimw.RequireProfile`. `serveEbookInline` (`ebook_reader.go:626`)
opens the original file and hands it to `http.ServeContent` wrapped in nothing but a
`RollingDeadlineWriter`; `serveConvertedEpub` (`ebook_convert_serve.go:160`) does the
same for the converted EPUB. There is **no metered writer, no transfer registry
entry, no `Refuse`, and no `WatchAndCut`** — none of the `guardRevocation*` wrappers
used by `/stream/{session_id}` (`api/router.go:2541`) are applied. Full Range support
is inherited from `ServeContent`. The accepted formats include `cbz`, `cbr` and `pdf`
(`ebook_reader.go:675`), so this is a large-file path, not a text path. A user
revoked for ripping previously kept pulling the entire comic/ebook library at full
speed while the admin transfer list showed nothing and `BytesServed` never moved.
**FIXED (GAP-11):** the route now registers a transfer record, meters its bytes,
refuses a revoked reader on entry and cuts an in-flight pour within ~5s. It stays
**cap-exempt** per decision A4, so reading never consumes a video stream slot, and
there is still no *volume* quota — a patient single-threaded rip remains possible.
**Detection ✅ / Enforcement ⚠️.**
**D19. Rip via sequential direct-play GETs.**
Playing items one at a time keeps `activeCountLocked == 1`, always under the cap.
Because the cap counts concurrency and never cumulative bytes, sequential
direct-play of every raw file is a fully-permitted rip. Each play *is* an
attributable session, but nothing correlates sequential sessions into "this user is
ripping." **Detection ⚠️ / Enforcement ❌.**
**D20. Admin stops an in-progress rip mid-transfer.**
`streamrevoke.Store.WatchAndCut` (checks on entry +
every 5s, forces the socket via `SetWriteDeadline`) and arms it on native downloads
(`api/handlers/downloads.go`) and compat download/direct-play
(`jellycompat/streams.go`). So a **user revocation now cuts an in-flight download**
— best-effort (no-op if the writer chain lacks write-deadline support, then stops on
next request). The admin still has to *decide* to revoke; nothing auto-detects the
rip. **Enforcement ⚠️.**
### Category E — API / DB load abuse (branch is orthogonal)
**E21. Infuse pulls the whole library list on every app start.**
Per-page size is hard-capped (`compatBrowseMaxLimit = 1000`,
`jellycompat/content_direct.go`; native 100), so no single call dumps 100k. But the
general paged browse (`handleBrowseItems` → `BrowsePage`) is **uncached and
unthrottled** — every app-start re-runs fresh SQL, and full-page requests add a
per-page `COUNT` over the filtered set. The `Latest`/hub rails *are* cached (15-min
shared resolved-list cache + singleflight), but that's a rail, not the full list.
Nothing attributes listing load per client/device. **Detection ❌ / Enforcement ⚠️
(first page cheap & capped; full enumeration pays full price each start).**
**E22. Thundering herd on cold cache.**
For `Latest`/hub rails, `singleflight` collapses concurrent identical builds into
one DB hit. For the **general browse there is no singleflight and no cache** — N
concurrent full-library enumerations run N independent SQL workloads, bounded only
by the shared pool (`userdb.pool_max_open`, default 500), beyond which requests
queue/time out rather than shed gracefully. **Detection ❌ / Enforcement ⚠️ (only
the cached rails are protected).**
**E23. Page a 100k+ library as fast as possible.**
Each page is capped at 1000 rows, but nothing caps the number of pages, the page
rate, or aggregate cost; deep pages incur a per-page filtered `COUNT` and rising
`OFFSET` cost. No rate limit on `/Items` (compat router mounts no throttle).
**Detection ❌ / Enforcement ❌ (beyond the per-page size cap).**
**E24. Expensive sorted/filtered views over the whole library.**
Some sorts are index-optimized (`recently_added` walks a dedicated index; the
multi-library case fans into per-library index walks + in-memory merge). But
arbitrary client sorts map through `mapSortBy` — `SortBy=DateCreated` "forces a
full-library top-N heapsort" (code comment), and genre/name-prefix/person filters
add WHERE/JOIN predicates with no cost cap beyond the LIMIT, plus a full-filtered
`COUNT` per page when `include_total` is on (default). No query-cost estimator, no
cache for filtered browses, no rate limit. **Detection ❌ / Enforcement ⚠️
(query-shape-dependent).**
**E25. Badly-coded client retry storm.**
`main` *does* ship `internal/ratelimit/` (token-bucket, default-on): global 1000
rps + per-IP 120 rps, mounted inside the **authenticated native group**
(`api/router.go`, after `RequireAuth`). So a storm against an authed native
endpoint is shed per-IP. But the limiter runs *after* auth, the entire
**jellycompat surface is unthrottled**, `/auth/refresh` has no limiter, the Redis
backend **fails open** on error, and the default memory backend is per-process (so
"global 1000 rps" is really per-instance). Attribution + telemetry exist
(Prometheus by path/status, access log with `client_ip`). **Detection ✅ /
Enforcement ⚠️ (native authed only).**
**E26. Tight-loop polling `/auth/refresh`.**
Registered as a plain public route (`api/router.go`) with **no** `AuthEndpointHandler`
and **outside** the authenticated group — it hits neither the global, per-IP, nor
per-endpoint limiter. A tight refresh loop is unthrottled. **Detection ✅ (logged) /
Enforcement ❌.**
**E27. Credential stuffing via compat `/Users/AuthenticateByName`.**
Native `/api/v1/auth/login` is capped at 20/min/IP via `AuthEndpointHandler`. The
Jellyfin-compat login has **zero throttling** — the compat router mounts no limiter
— and there is no account-level lockout/backoff anywhere. **Detection ✅ /
Enforcement ❌ (compat path wide open).**
**E28. Many concurrent HTTP connections / slowloris.**
No connection cap: no `netutil.LimitListener`, no per-IP connection limit, no
handler semaphore. The `http.Server` sets only Read/Write/Idle timeouts (and the
compat + ABS servers even set `WriteTimeout: 0`). The rate limiter counts
*requests*, not *connections*. **Detection ❌ / Enforcement ❌.**
**E29. Spawn many concurrent transcodes to exhaust CPU.**
Only the per-user `max_transcodes` (default 2, `<=0` = unlimited) is checked, at
admission. There is **no per-node or server-wide ffmpeg/CPU cap** on fresh starts
(the `reconstructSem` NumCPU semaphore guards only the restart-reconstruct path).
Node scheduling has an optional `MaxJobs`/`MaxBandwidthKbps` per node
(`nodepool`), but both default to unlimited. N users at a high per-user cap — or one
user with `max_transcodes <= 0` — can saturate the box. **Detection ⚠️ (sessions
visible, no CPU/process signal) / Enforcement ❌ (no aggregate cap).**
---
## What the branch genuinely delivers vs. what it does not
**Delivers (and it's solid):**
- Authoritative, **byte-observed** stream existence on the *playback* surfaces that a
client cannot hide by lying about or withholding progress (A5) — with the two
bounded caveat GAP-15 noted there, and now **including** ebooks (GAP-11 fixed) and
subtitle pours. GAP-13 is fixed, so merged byte totals are trustworthy too, and
GAP-14 is fixed: a download-class pour that cannot be monitored is now **refused**
rather than served blind.
- A durable, restart-surviving, reconnect-proof **kill switch** for a *specific
session* or a *user*, via one shared `Refuse` + `WatchAndCut` (B9–B12, D20).
`Refuse` — the next-request half — holds everywhere it is mounted. The
`WatchAndCut` half was weaker than previously documented — it no-op'd on all ABS
routes (GAP-10), was racy on rolling-deadline routes (GAP-12) and was absent from
ebooks (GAP-11) — and **all three are now fixed**, so it lands on every mounted
surface. Two honest bounds remain: the cut takes up to one **~5s** watch tick, and it
does **not** "keep a stream dead" — an over-cap kill still reopens after 5m until
decision A1 lands.
- Immediate **synchronous admission** refusal of over-cap starts, and an async
over-cap reconciler for the multi-node picture (A1, A2, C15) — *when a per-user
cap is set*. Since A6, integrated sessions are published to the shared
`silo:sessions:` picture and a single elected evaluator trims cross-replica excess.
**Does not cover (by scope or by gap):**
- **Distributed synchronous admission.** A6 gave every replica the same *monitoring*
picture, but `StartSession`'s cap check still reads only the local `SessionManager`.
Streams spread across replicas are all admitted and then trimmed asynchronously
within the ~120s budget, not refused at the door.
- **Under-cap re-streaming** (C14) and **token hoarding/fan-out** (C16) — no
detection, no enforcement; the heuristic that was scoped for this is unimplemented.
- **Bulk ripping** by download (D17/D18) or sequential direct-play (D19) — the cap
is a *concurrency* gate, not a *volume* quota; compat download is entirely
unquota'd.
- **Library-enumeration DB load** (E21–E24) — orthogonal subsystem; per-page capped
but per-client-uncached, unthrottled, and unattributed.
- **API floods on the compat surface, `/auth/refresh`, and connection exhaustion**
(E25–E28) — rate limiter has real coverage gaps.
- **CPU/transcode exhaustion** (E29) — no aggregate cap.
## Settled design decisions (2026-07-30)
These were open forks blocking the remaining batches. All are now decided, so follow-up
issues inherit them rather than re-litigating.
| # | Question | Decision |
|---|---|---|
| **A1** | Over-cap enforcement model | **Long revocation** matching the token's reconstructable lifetime, so an over-cap kill stops reopening every 5m. **Must not ship before the tracker-lifecycle batch** — it removes the self-healing property that currently limits the damage of a wrong count, and every known source of a wrong count is in that batch. |
| **A2** | Revocation state model | **Durable tombstones** — an independent `RevokedAt`/`ExpiresAt` merge *plus* a durable tombstone so an un-ban survives a restart and cannot be re-`Upsert`ed by a stale replica. One Goose migration. |
| **A3** | Credential identity for the user cutoff | **Presented credential time.** Native access uses token `iat`; normal Jellyfin compatibility sessions use their stable login `CreatedAt`, not the refreshed bridged token. API keys and valid JWTs without `iat` use zero and deliberately fail open, so a user cutoff cannot cut their pours. Per-login logout cuts stay out of scope (they need authorization-generation/per-login identity in the stream credential). |
| **A4** | What counts as a "stream" | **Observe + make killable, keep cap-exempt** for the ABS bare file route and ebook/comic/PDF reading. Neither consumes a video stream slot. **Implemented.** |
| **A5** | Liveness source of truth | **Separate server-observed liveness entirely.** Client progress never feeds enforcement or reaping; the `LastActivityAt` fallback for `LastServedAt` is removed, and reaping measures from `LastServedAt` (falling back only to `StartedAt`) behind a configurable never-served grace. Paused sessions holding an open realtime/WebSocket connection stay exempt — an observed, ping-checked connection is server-observed, unlike a reported progress position, and this preserves the issue #243 fix. **Implemented.** |
| **A6** | Multi-replica visibility | **Publish every integrated stream to Redis** so all replica enforcers share one picture, keyed by a per-process instance id. A renewable Redis lease elects one evaluator per tick so replicas cannot trim from divergent snapshots. Fixes **monitoring** only — synchronous admission stays per-process. **Implemented.** |
| **A7** | Fail-open vs fail-closed monitoring | **Fail closed + connection cap.** Download-class pours are refused (429/503 + `Retry-After`) rather than served unmonitored, and a per-user concurrent-transfer cap (`playback.max_user_concurrent_transfers`, default 24) makes registry saturation unreachable by one actor. **Implemented.** |
| **A8** | Scope | **Split.** The serve-path, capability and docs work lands first; tracker lifecycle → revocation state → liveness/replicas → re-stream heuristic follow in that dependency order. |
A new finding recorded while implementing A4, for the A3 batch: `streamRequestIdentity`
verifies a stream token's signature but never checks that the token's `SessionID`
matches the `session_id` in the URL, so a valid token for one session can supply
identity on another session's route. It belongs with A3 rather than being fragmented
across two batches.
## Recommended follow-ups (prioritized by exposure)
**Round-2 defects come first — these are open bugs in shipped behavior, not
enhancements.** In rough fix-cost order:
0a. ~~**Add `Unwrap()` to `abs.statusRecorder`**~~ — **DONE.** Plus `WatchAndCut` now
logs (once per watcher) rather than discarding a failed `SetWriteDeadline`, and the
replacement test drives the *mounted* router over a real socket. Verified
non-vacuous: it fails if `Unwrap()` is removed again. (GAP-10)
0b. ~~**Wire the ebook routes into the stream kill switch and the transfer registry**~~
— **DONE**, cap-exempt per decision A4. ⚠️ **The original advice in this line —
"reuse `guardRevocationCut`" — was wrong and was not followed.** That wrapper reads
`chi.URLParam(r, "session_id")`, which the ebook route does not have (it would pass
`""`), and takes identity from `streamRequestIdentity`, which looks for a `?st=`
stream token the route never carries. It compiles and silently guards nothing. The
ebook fix takes identity from the profile/auth context and otherwise follows the ABS
file-handler idiom. (GAP-11)
0c. ~~**Make the revocation cut survive the rolling deadline**~~ — **DONE** via a
`CutLatch` on the request context. Note the sticky-flag-on-`RollingDeadlineWriter`
option suggested here could not be implemented as written: the rolling writer is
constructed *inside* `ServeDirectPlay`/`ServeRemux` and wraps the writer the watcher
holds, so it sits *above* the watcher and `Unwrap()` (which walks toward the socket)
can never reach it. Hence the context side channel. The re-applying watcher was also
kept, as belt-and-braces. (GAP-12)
0d. ~~**Merge `BytesServed` as a max, not wholesale**~~ — **DONE** in both
`mergeStreams` and `DedupeSessionInfos` (GAP-13). **GAP-15 is also DONE:** edge
transcode visibility is created before proxying, while liveness and byte totals
advance only for bytes actually written from a 200/206 upstream response.
0e. **Monitoring projection is not uniformly asynchronous.** The first Redis write
for each edge session remains synchronous so the record is visible before the
request returns; subsequent liveness and byte projection happens asynchronously
on the refresh tick. A slow Redis therefore adds latency to the first request of
a stream. The proposed ordered, bounded projection queue is deliberately deferred
until its startup, drain, cleanup ordering, backpressure, and refresh interaction
can be designed together.
0f. ~~**Decide the registry-saturation policy explicitly**~~ (GAP-14) — **DONE (A7).**
Download-class pours fail *closed* once the registry is full (429/503 with
`Retry-After`), and a per-user concurrent-transfer cap
(`playback.max_user_concurrent_transfers`, default 24) makes saturation
unreachable by a single actor. Five call sites, not four — `ebook_reader.go` was
missing from the original list. Note this does **not** close E28: there is still
no server-wide connection cap, only a per-user transfer cap.
0g. ~~**Bound the kill-list propagation lock.**~~ **DONE.** Redis and pub/sub run
before the durable mirror, and detached propagation/startup warm use bounded
contexts. `opMu` deliberately remains held across propagation so a same-process
unrevoke cannot interleave with an older mirror write. The cross-replica race on
the Redis mirror is **now also closed**: `mirrorToRedis` merges server-side in Lua
with the same monotonic semantics as `applyLocal`, comparing exact `(sec, nsec)`
pairs and merging *before* deciding to delete, so a lapsed incoming revocation
cannot remove a live stronger one. Falls back to the previous plain `SET` (logged
once) if the script cannot run.
Then, as originally scoped:
1. **Download controls phase 2:** add per-download kill and a standing per-user
download block. The existing user revocation is a cutoff, not a ban: it cuts
older pours but deliberately allows new ones.
2. **Unify download quotas and add a per-user *volume* budget** (D17/D19): cover
native serving, compat, and ABS with a rolling bytes-per-period cap that spans
downloads and direct-play, since concurrency caps cannot bound a rip.
Also deferred: correct native completion semantics after partial/write-failed
responses, correlate duplicate ABS playback-session and `abs_file_stream`
views, and aggregate transfer visibility across API replicas.
3. **Extend rate limiting to the compat surface + `/auth/refresh`** and add a
connection cap / `LimitListener` (E25–E28).
4. **Add a per-node concurrent-transcode cap** and wire `nodepool.MaxJobs` defaults
(E29).
5. **Implement the restream heuristic** (C16 fan-out; **not** C14): the fingerprints do
**not** already flow in usable form — see correction #9. A distinct-viewer-IP rule
needs per-request observation at media-serve time. The edge trust-boundary half is
**done**: `internal/proxy/server.go`'s `edgeClientIP` now resolves through the
operator's `clientip.trusted_proxies` boundary like the native surface, so viewers
behind an ingress are distinguishable and a forged `X-Forwarded-For` from an untrusted
peer is ignored. Tracked as issue #522.
Decisions are now settled: distinct viewer IPs per session as the signal, Redis
with a ~24h TTL when configured and in-memory otherwise, detection **on by
default and alert-only**, with auto-kill behind an operator setting that defaults
to off. Note the privacy posture explicitly — this retains per-viewer IP history
on every deployment.
6. **Per-client listing-load accounting** (E21–E24): attribute browse cost per
device and add caching/singleflight to the general paged browse, not just the
Latest/hub rails.
## Accepted limitations from the liveness/replica batch (2026-07-31)
Deliberate, recorded decisions. Changing any of them is a new decision, not a bugfix.
- **Paused sessions age from the last served byte, not the last client ping.** A paused
session holding an open realtime/WebSocket connection stays exempt from reaping; one
whose client only POSTs progress is reaped after the 30m paused grace and resumes via
the restart-resilient reconstruct path. This is the A5 boundary: an observed,
ping-checked connection counts as liveness, a reported position does not.
- **A never-served session is retained for a bounded grace** (`DefaultUnservedSessionGrace`,
2m) so a slow first byte is not reaped instantly. It is a separate configurable knob
rather than a floor on `SetLivenessGracePeriods`, which would otherwise silently
override an operator's shorter window.
- **`last_served_at` is omitted for integrated sessions that have never served.** The
admin session list shares the enforcer's projection, so it no longer reports a client
progress timestamp as a serve time. The field is unchanged when real bytes exist.
- **Synchronous admission remains per-process.** See "does not cover" above.
- **The integrated session publisher waits one 10s tick before its first publish**, so a
freshly-started replica's sessions are briefly absent from the shared picture. Bounded
and self-correcting; that replica's own enforcer still sees them through the local
source.
- **The enforcer lease is advisory.** A Redis error means every replica evaluates, which
is the pre-A6 behavior — failing to coordinate must never mean failing to enforce.
- **The Redis revocation merge degrades to a plain `SET`** if the Lua script cannot run
(logged once). Behavior then matches pre-A6.
- **Transfer-saturation rejections are logged at Warn inside the registry**, rate-limited
per user and carrying route and user id — not once per rejected request at each call
site, which an actor parked at its cap could turn into a log-amplification vector.
- **`playback.max_user_concurrent_transfers` set too low will 429 legitimate
multi-connection downloaders**, which routinely open 4-8 sockets per file and consume
one transfer id per concurrent Range request.
- **The Lua-executing tests skip without `SILO_TEST_REDIS_ADDR`**, and this repository
runs no `go test` job in CI, so that evidence is local-only until a Redis service is
added to CI.
## AI-use disclosure
This assessment was produced with AI assistance, from a read-only audit of the
`feat/sauron-async-enforcer` branch and current `main`. No code behavior was changed.
Findings cite the code as of the audit; the corrections above are where the branch's
own plan/coverage docs overstate what the shipped code does.
Round 1 (corrections 1–3) was produced with Claude. Round 2 (corrections 4–8, dated
2026-07-29) was a **two-model** pass: Claude Opus 5 and Codex `gpt-5.6-sol` reviewed
the rebased branch independently, and every finding recorded here was re-verified
against the code by hand before being written down. Corrections 4, 6, 7 and the
`opMu` follow-up (0f) originated with Codex; corrections 5 and 8 were found by both
models independently. Two Codex claims were **rejected** on verification and are
deliberately not recorded above: that chi's `middleware.Compress` wrapper breaks the
`Unwrap` chain (chi v5.2.5 implements `Unwrap()` at `middleware/compress.go:374`),
and that client progress reports advance the central `Session.LastServedAt` (only
`BeginTransport`/`EndTransport`/`AddServedBytes` write it — the real mechanism is the
edge `Touch`, recorded as GAP-15).
Round 3 (2026-07-30) implemented corrections 4–7 as a **cross-model relay**: Claude
Opus 5 (`claude-opus-5[1m]`) planned, reconciled and reviewed; Codex `gpt-5.6-sol` at
medium effort adversarially reviewed the plan, implemented it, and took one remediation
round. Involvement classification: **AI-assisted implementation with human-directed
scope and AI cross-review**; every design fork (A1–A8) was decided by the maintainer,
not by either model.
Adversarial findings and their resolution, recorded because the disclosure standard
requires it:
- Codex's plan review found the decisive issue: fixing GAP-10 alone is **inert**,
because the same `Unwrap()` that makes `SetWriteDeadline` work also makes
`RollingDeadlineWriter.bump()` work, and `bump()` erases the cut. GAP-12 was pulled
forward from a later batch as a result. **Accepted.**
- Codex correctly refuted two claims in the Claude plan: that native subtitle URLs carry
a `?st=` stream token (they do not — `subtitleStreamURL` emits only `file_id`), and
that the `transfers` capability belonged on `/admin/sessions/capabilities` (it belongs
to `/admin/node-sessions`, which is separately gated). It also correctly refuted a
claimed "latent nil bug" in the ABS transfer defer — `Registry.End` guards a nil
receiver. **All accepted; the plan was corrected before implementation.**
- Claude's review of the Codex implementation found five defects, all confirmed by
reading or by running the tests: a **failing real-socket ebook test** (Codex's sandbox
could not run `httptest` listeners, so every real-socket test it wrote was
unexecuted); **GAP-12 left unfixed on the proxy edge remux path**; Warn-log spam every
5s from the now-perpetual watcher; `HandleVideoStream` still using three separate
`time.Now()` values — the exact race Codex itself had raised and fixed only in the
subtitle path; and subtitle metering counting error-response bodies. **All five fixed
in one remediation round and re-verified here.**
- Claude's Batch-5 capability code was then reviewed by Codex, which found that the
vocabulary drift test was not genuinely bidirectional — it sampled rejected strings,
so a kind newly *accepted* by the parser could go unadvertised. **Accepted:** the
parser and the advertisement now read one shared map.
Verification note: `pnpm` is absent on the host used for this round, so `make build`,
`pnpm run lint` and `pnpm run format:check` could not be run locally and must come from
CI. No frontend files were changed. The `internal/access` and `internal/jellycompat`
test packages **do not compile on `main`** (stale `UserStore` doubles missing
`GetOnboardingState`), so the compat changes in this round are **verified by reading
only** — CI cannot exercise them either until that is fixed separately.
Round 5 (2026-07-31) implemented the liveness/replica batch (A5, A6, A7) as the same
cross-model relay: Claude Opus 5 planned and reviewed, Codex `gpt-5.6-sol` at medium
effort adversarially reviewed the plan and implemented it in three commit-sized passes,
and Claude re-ran every verification independently.
- Codex's plan review returned 14 findings. The load-bearing ones, all **accepted**:
the proposed Lua merge's `ttl <= 0 ⇒ DEL` could delete a *stronger* concurrent
revocation, so the script must merge before deciding to delete; the repository's Redis
test doubles are hand-written go-redis `ProcessHook` fakes that **cannot execute Lua**,
so the originally-proposed merge tests would have proven nothing; a hardcoded
never-served grace would silently override `SetLivenessGracePeriods`' documented
contract and break four existing tests; and the per-user cap option could not express
both "unset ⇒ default" and "explicit 0 ⇒ unlimited" as a plain `int`.
- Two review claims were **rejected** on verification. That the new setting required an
Admin UI field (and therefore frontend changes): `playback.over_cap_revocation_ttl`,
added by Batch 5 on this same branch, has identical backend plumbing and is
deliberately absent from `PlaybackSettings.tsx`'s `KEYS` list, so backend-only matches
the branch's own precedent. And that the setting should be parsed near the enforcer
wiring: the registry is constructed far earlier, so it is parsed at the construction
site from the settings map already loaded at startup.
- Claude's own planning found a conflict the decision record did not anticipate: A5 says
client progress must never feed reaping, but `session_paused_grace_test.go` encodes
issue #243, where reaping a paused transcode froze clients. Resolved by keying the
paused exemption on an open, **ping-checked** realtime/WebSocket connection — a
server-observed connection rather than a client-reported position — which satisfies
both.
- Codex's sandbox **blocks loopback TCP**, so the six real-Redis tests it wrote never
executed there; it reported this explicitly rather than claiming they passed. Claude
ran them against a real Redis 8.6.2 (`EVAL` + `cjson` confirmed) and then
mutation-checked them by bypassing the Lua merge back to the old unconditional `SET`:
four of five failed, confirming they genuinely exercise the merge.
- Claude's review found one defect Codex missed: `TestHandleListSessions...` asserted
`last_served_at == LastActivityAt`, encoding the very fallback A5 removes. It surfaced
only when running `./internal/api/...`, which the first verification pass skipped.
Fixed, and the test now also asserts the field reappears once bytes are served.
Verification note for this round: the Lua-executing tests are gated on
`SILO_TEST_REDIS_ADDR` and were run locally against Redis 8.6.2. This repository has
**no CI job that runs `go test`** at all, so all Go test evidence here is local. `pnpm`
remains absent on this host; no frontend files were changed in this round.
-21
View File
@@ -18,27 +18,6 @@ When the scope locks, this file becomes the source of truth and will contain:
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
for feature PRs (see the scope gate in `CLAUDE.md`).
## Breaking removals taken before lock
The additive-only rule in item 2 binds at lock. Before then a removal is in scope, and there is no
amendment to write because the amendment process in item 3 does not exist yet. `CLAUDE.md` states
the rule without that qualifier, which reads as a contradiction — it is not, but a removal taken
now has to be recorded here so a reader after lock can tell a deliberate decision from a violation.
Each entry names what goes, why waiting is worse, and the design that decided it. **Every removal
listed here must have shipped before the scope locks.** One still outstanding at lock loses its
justification and falls back to the Deprecation/Sunset flow like anything else.
| Removed | Release | Rationale |
|---|---|---|
| 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
`GET /api/v1/libraries/provider-defaults` rather than version sniffing. New capabilities
+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.*`):
`audiobooks.enabled`, `playback.protocol_v3_enabled`):
- `diagnostics.uploads_enabled` (bool, default `false`)
- `diagnostics.max_bundle_bytes` (default 10 MiB compressed)
@@ -1,8 +0,0 @@
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.
@@ -1,123 +0,0 @@
{
"$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
}
@@ -1,793 +0,0 @@
{
"$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
}
@@ -1,22 +0,0 @@
{
"$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
}
@@ -1,113 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,99 +0,0 @@
{
"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
}
@@ -1,162 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,45 +0,0 @@
{
"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"
}
}
@@ -1,8 +0,0 @@
{
"enabled": false,
"protocol_versions": [],
"features": [],
"deliveries": [],
"transformations": [],
"reason": "disabled"
}
@@ -1,110 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,61 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,3 +0,0 @@
{
"error": "client_upgrade_required"
}
@@ -1,98 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,113 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,5 +0,0 @@
{
"protocol_version": 3,
"server_features": [],
"outcome": "playable"
}
@@ -1,113 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,98 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,110 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,98 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,98 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,98 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,50 +0,0 @@
{
"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"
]
}
]
}
@@ -1,180 +0,0 @@
{
"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"
}
}
@@ -1,4 +0,0 @@
{
"error": "client_upgrade_required",
"message": "This server requires playback protocol v3. Update the app to continue."
}
@@ -1,113 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,99 +0,0 @@
{
"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": []
}
}
}
}
@@ -1,663 +0,0 @@
{
"$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
}
@@ -1,95 +0,0 @@
{
"$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
}
@@ -1,545 +0,0 @@
{
"$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
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

-198
View File
@@ -1,198 +0,0 @@
# 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,248 @@
# Stream Monitoring & Kill-Switch Implementation Plan
> **Status: IMPLEMENTED (with a minor operator-config follow-up open)** on branch `feat/sauron-async-enforcer`. All three phases shipped — Phase 1 (authoritative server-side monitoring), Phase 2 (revocation kill switch + edge/native/jellycompat enforcement), and Phase 3 (async over-cap enforcer). The one still-open item is operator ergonomics, not a phase: the `auth.stream_revocation_poll` / `auth.stream_revocation_ttl` keys are not yet wired through the settings pipeline (defaults 60s / 24h are hardcoded) — see "As-built deltas" and the coverage matrix's follow-up list. A durable Postgres mirror of the kill list was added **after** this plan was written, so kills now survive a server restart or a Redis flush (this plan's "optional Postgres durability" is no longer optional — it closes the gap where restart-resilient playback could reconstruct and re-serve an already-killed stream). For the **as-built** coverage across server layout × playback type × route, the resolved gap findings (GAP-1..GAP-4), and the open verification items, see the companion reference: [`playback-paths-monitoring-kill-matrix.md`](../../architecture/playback-paths-monitoring-kill-matrix.md). The task checkboxes below are retained as the original plan of record.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Do not edit this plan file while implementing it.**
**Goal:** Gain control over stream abuse (concurrency-cap violations, admin termination, Stremio-style re-streaming) **without** putting the central database on the per-segment hot path and **without** changing the client-facing token protocol (the later-added `Origin` route claim is server-signed and opaque to clients — they only echo the token, so the wire protocol clients see is unchanged). Do it with two primitives plus an off-hot-path decision loop: (1) authoritative server-side monitoring that never trusts the client, and (2) a kill switch that stops any stream within ~120s and keeps it dead.
**Architecture:** Split the problem into *detection* and *enforcement*, and keep both off the hot path.
- **Detection = authoritative monitoring (no client trust).** Bytes can only reach a viewer by flowing through the edge (segment serving) or a held-open direct-play socket that the edge itself is writing. So the server *inherently* knows every live stream — it is the one sending the bytes. We make the existing `nodesessions` records the source of truth, refreshed by *real serve activity* (segment served / active transport / open connection), enriched with `last_served_at` and bytes, and mirror the same signal into integrated mode. No client progress report is trusted for liveness. A stream that "plays quietly" is still pulling segments (or holding a socket) and is therefore visible; one that stops pulling ages out on its own.
- **Enforcement = a revocation kill switch.** A small revocation set lives in Redis (multi-node) or in-memory (integrated), mirrored to Postgres for durability (shipped as a wired part of the kill path, not an optional add-on — it is what lets a kill survive a restart/Redis flush). Edges cache it in memory, refreshed by pub/sub push (near-instant) plus a ≤60s poll (safety net). The **only** hot-path addition is a local in-memory set-membership check per segment — no per-segment Redis or Postgres. On a hit the edge refuses the request and cuts the connection; for direct-play the edge periodically re-checks during the long GET and aborts. A kill "stays dead" because the revocation entry outlives the token and every reconnect is refused.
- **Decision = an async brain, fully off the hot path.** A central background evaluator reads the live monitoring picture and the per-user limits (`users.max_streams`), and decides what to kill: over-cap victims, admin-terminated sessions, and (pluggable) abuse heuristics. Every reason — exceeded limit, admin terminate, Stremio abuse — collapses to the *same* action: write a revocation. Enforcement follows within ~120s. The delay is acceptable for a media server.
The client-facing token **wire protocol is unchanged**. Its `sid`/`uid`/`pid` claims are the revocation keys; the server may add opaque server-signed claims (e.g. the later-added `Origin` route claim) that clients only echo. Nothing about mint sites, segment URLs, HLS manifests, or client behavior changes. This is why the approach is low-risk relative to a short-ticket redesign (see "Background — why not short tickets").
**Tech Stack:** Go, chi routers (proxy/transcode edge + native api), `redis/go-redis/v9`, Postgres via pgx + Goose migrations, the existing `nodeconfig.Watcher` settings pipeline, the `cache` package Redis client + `EventBus` pub/sub.
---
## Commands
Commands assume the repository root is the cwd.
- Build: `make build`
- Backend only: `go build ./... && go vet ./...`
- Lint: `make lint`
- New migration: `make migrate-create NAME=revoked_stream_sessions`
- Local services: `docker compose up -d postgres redis`
---
## Background — what we learned (why this shape)
Audited integration points this plan builds on (do not re-derive):
- **Stream token** is a stateless signed JWT, 24h TTL, verified at the edge by signature only, no store, no revocation today (`internal/streamtoken/token.go`; mints at `internal/api/handlers/playback.go:517,1601,2159` and `internal/jellycompat/handlers_playback.go:317`). Claims carry `sid` (`SessionID`), `uid`, `pid`, `mfid` — our revocation keys.
- **Edge already sees every segment and already writes Redis session records.** Proxy `touchTranscodeSession` → `nodesessions.Tracker.Touch` on every manifest/segment (`internal/proxy/server.go:192`; `internal/nodesessions/tracker.go:137`), 60s TTL, refreshed every 30s. Direct-play/remux are tracked for the request lifetime (`proxy/server.go:143-145,156-158`). Records already carry `AuthUserID`/`ProfileID`/`MediaFileID` (`tracker.go:41-43`) but **no** `last_served_at` or bytes yet.
- **Edge egress is already metered** but only as a node aggregate (`internal/proxy/egress.go`), not per session.
- **Edge Redis is mandatory** and constructed once (`cmd/silo/main.go:576-580,605`), reused for the recipe store (`main.go:623`) — reuse the same client for a revocation store via a `SetRevocationStore(...)` wired exactly like `SetRecipeStore`.
- **Central Redis** is `apiRedisClient` (`main.go:672`, may be nil on Redis-less single-node), injected as `deps.RedisClient` (`internal/api/router.go:135`). Central and edges point at the **same** Redis in multi-node (existing `silo:sessions:*` write/read contract proves it).
- **Config auto-propagates to edges** via `nodeconfig.Watcher` reading `server_settings` (`internal/nodeconfig/watcher.go`); follow the `jellyfin_compat.session_ttl` precedent (`internal/config/config.go:217,233`; `internal/config/db_loader.go:381`) for any new key.
- **Pub/sub already exists** on `cache.ChannelAdmin` and the watcher subscribes to it (`watcher.go:83-93`) — reuse this channel/pattern to push revocations to edges near-instantly.
- **Revocation hook exists but is compat-only.** `OnUserSessionsRevoked` (`router.go:162`, `admin.go:101`, impl `cmd/silo/main.go:2047`) currently only deletes Jellyfin-compat sessions. Admin terminate (`internal/api/handlers/admin_playback_control.go:63` `HandleTerminateSession` → `handleSessionCommand`) currently only sends a realtime command + `stopPlaybackSessionByID` fallback; it does **not** revoke the stream credential at the edge. These are the exact hooks to extend.
- **Concurrency counting already exists in-process** (`internal/playback/session.go` `SessionManager`, per-user `activeCountLocked`, `SessionLimitProvider` reading `users.max_streams`) but only for the local API process and only at admission. The async brain reuses the *limit provider* but computes the live count from the monitoring picture instead of the in-process map, so it works across nodes.
**Why not short tickets (the rejected alternative):** turning the 24h token into a short-lived play ticket would enforce the cap continuously, but (a) Silo's `master.m3u8` is a **VOD** playlist fetched once (`internal/playback/transcode.go:1231` `GenerateFullManifest`), so short tickets embedded in segment URLs would expire mid-movie and 401 the rest of the stream — the "renewal rides on playlist refresh" premise does not hold; (b) flipping the global token TTL is a break-all-playback change that cannot be behaviorally verified without real clients. The monitor-and-kill approach accepts a ~120s enforcement delay in exchange for zero client-protocol change and zero hot-path DB/Redis.
---
## File Structure
### Phase 1 — Authoritative monitoring (no client trust)
- Modify `internal/nodesessions/tracker.go`
- Add `LastServedAt string` and `BytesServed int64` to `SessionInfo`.
- Refresh `LastServedAt` (and accumulate `BytesServed`) on real serve activity, not just first touch. Keep the Redis write throttled (only re-`SET` when the record materially changes or on the 30s refresh) so this stays cheap.
- Add a `Snapshot(ctx) ([]SessionInfo, error)` helper (edge-local view) if useful for `/status`.
- Modify `internal/proxy/server.go`
- Feed real bytes-served per session into the tracker (wrap the segment/direct/remux response writers, or reuse the `egressMeter` write hook keyed by `claims.SessionID`).
- Ensure direct-play/remux held-open connections keep `LastServedAt` fresh periodically (a ticking update while `io.Copy`/`ServeFile` runs), so a long quiet pour is never invisible.
- Modify `internal/proxy/egress.go`
- Allow the metered writer to report per-session deltas to a callback (so the tracker can attribute bytes), in addition to the existing node-aggregate rate.
- Modify `internal/transcodenode/server.go`
- Mirror the same `last_served_at`/bytes tracking on the node's own segment serve path (`handleSegment`).
- Modify `internal/api/handlers/stream.go` and `internal/api/handlers/playback.go`
- Integrated mode: on the native segment/direct serve paths, record server-side serve activity (last-served/bytes) into the same monitoring surface (in-memory for single-node; the tracker if Redis present). Stop relying on client progress reports for liveness. Reuse `BeginTransport`/`EndTransport` (`internal/playback/session.go:577,593`) plus a serve-activity touch.
- Modify `internal/api/handlers/nodes.go`
- Extend `HandleListSessions` (`:303`) to surface `last_served_at`/bytes so the admin view and the brain see the authoritative picture.
- Create `internal/streammonitor/monitor.go`
- A thin central-side aggregator that returns a normalized "live streams" snapshot: read Redis `silo:sessions:*` (multi-node) or the in-process `SessionManager`/tracker (integrated), keyed by `uid`, with `sid`, method, `last_served_at`, bytes, node. This is the single input to the async brain and to admin monitoring.
- Create `internal/streammonitor/monitor_test.go`
- Table tests over synthetic session records: liveness aging, per-user grouping, multi-node de-dup by `sid`.
### Phase 2 — Kill switch (revocation) + enforcement
- Create `internal/streamrevoke/store.go`
- Interface `Store` with `Revoke(ctx, key RevKey, reason string, until time.Time) error`, `IsRevoked(key RevKey) bool` (local, hot-path, in-memory), `List(ctx) ([]Revocation, error)`, `StartSync(ctx)`.
- `RevKey` addresses a session (`sid`) or a user (`uid`); a user revocation matches all that user's sessions.
- In-memory backend (integrated): a `map`/set behind an RWMutex with TTL entries; `Revoke` writes locally.
- Redis backend (multi-node): `silo:revoked:sess:{sid}` / `silo:revoked:user:{uid}` keys with TTL = token remaining life (default 24h); `StartSync` warms an in-memory cache from a `SCAN` on start, subscribes to `cache.ChannelAdmin` for push updates, and polls every ≤60s as a safety net. `IsRevoked` reads only the in-memory cache (no per-call Redis).
- Postgres durability mirror (see migration), wired central-side, so kills survive a Redis flush/restart; repopulate Redis + cache on startup.
- Create `internal/streamrevoke/store_test.go`
- Cover: local cache hit/miss, TTL expiry, user-key matches session, pub/sub push applies, poll reconciles, durability repopulation.
- Create `migrations/sql/<timestamp>_revoked_stream_sessions.sql` (via `make migrate-create NAME=revoked_stream_sessions`)
- Table `revoked_stream_sessions(kind text, key text, reason text, revoked_at timestamptz, expires_at timestamptz, primary key(kind,key))` for durable kills; index on `expires_at` for pruning.
- Modify `cmd/silo/main.go`
- Construct the revocation store on both sides: edge (reuse `redisClient` from `main.go:576`, wire `srv.SetRevocationStore(...)` next to `SetRecipeStore` at `:623` and the proxy equivalent); central (reuse `apiRedisClient` `:672`, fall back to in-memory when nil). Call `StartSync` for edges.
- Extend the `OnUserSessionsRevoked` closure (`:2047`) to also `Revoke(user:{uid})` in the store, so account-level revocation kills streams too.
- Modify `internal/proxy/server.go`
- In `verifyToken` (`:126`) / each serve handler: after signature verify, `if revStore.IsRevoked(sid or uid) { close + 403 }`. For direct-play/remux, run a periodic re-check during the long serve (a deadline/ticker goroutine bound to the request context) that aborts `io.Copy`/`ServeFile` and closes the connection when revoked.
- Modify `internal/transcodenode/server.go`
- Same revocation check on `handleSegment`/`handleManifest`; refuse + stop feeding a revoked session (and tear down its ffmpeg via the existing session teardown, but ALSO keep the revocation so `reconstructFromToken` refuses to rebuild it — otherwise the node self-reconstructs the kill away, `server.go:620`).
- Modify `internal/api/handlers/admin_playback_control.go`
- In `handleSessionCommand` for `terminate` (`:63`): in addition to the realtime command + `stopPlaybackSessionByID`, call `revStore.Revoke(sess:{sid})` so termination sticks at the edge regardless of client cooperation. Add an explicit `stop`-vs-`terminate` semantic: `terminate` revokes; `stop` remains a cooperative request.
- Modify `internal/api/router.go`
- Inject the revocation store into the handlers that need it (`Dependencies` field, like `RedisClient`).
- Modify `internal/config/config.go`, `internal/config/db_loader.go`, `internal/config/yaml_import.go`
- Add `auth.stream_revocation_poll` (default `60s`) and `auth.stream_revocation_ttl` (default `24h`) following the `jellyfin_compat.session_ttl` precedent. Decide restart-required vs hot-reload (`internal/config/restart_keys.go`); prefer hot-reload.
### Phase 3 — Async decision engine (the brain)
- Create `internal/playback/enforcer.go` (or `internal/worker/stream_enforcer.go`, following the `internal/worker/cleanup.go` ticker precedent)
- A background loop (central only) that every N seconds: pulls the live snapshot from `streammonitor`, groups by `uid`, reads each user's limit via the existing `SessionLimitProvider` / `users.max_streams`, and for any user over cap selects victims (default: most-recently-started beyond the cap) and calls `revStore.Revoke(sess:{sid}, "over_cap")`.
- Pluggable rule interface `Rule(snapshot) []KillDecision` so admin/abuse rules compose. Ship two rules: `OverCapRule` and a stub `RestreamHeuristicRule` (documented, disabled by default) that flags sustained N-distinct-title concurrency / throughput anomalies per `uid`.
- Structured logging + metrics for every kill: who, why, which rule.
- Create `internal/playback/enforcer_test.go`
- Deterministic tests: over-cap victim selection, no-op when under cap, idempotent re-revocation, limit-provider error → fail-open (do not kill).
- Modify `cmd/silo/main.go`
- Start the enforcer loop on central (integrated/api), wire it to `streammonitor` + the limit provider + `revStore`.
- Modify `internal/api/handlers/admin_playback_control.go` / a new admin endpoint
- Optional: expose the current kill decisions / recently revoked sessions for the admin UI ("why was this killed").
---
## Implementation Tasks
### Phase 1 — Authoritative monitoring
- [ ] Add `LastServedAt` + `BytesServed` to `nodesessions.SessionInfo`; refresh on real serve activity with a throttled Redis write.
- [ ] Wrap edge serve writers (proxy segment/direct/remux) to attribute per-session bytes + keep `LastServedAt` fresh during long pours; extend `egressMeter` with a per-session delta callback.
- [ ] Mirror serve-activity tracking on the transcode node (`handleSegment`).
- [ ] Integrated mode: record server-side serve activity on native serve paths; stop trusting client progress for liveness.
- [ ] Add `internal/streammonitor` aggregator returning the normalized per-`uid` live snapshot (Redis and in-process backends) + tests.
- [ ] Surface `last_served_at`/bytes in `HandleListSessions` for the admin view.
- [ ] `go build ./... && go vet ./...`; manual check that a playing stream appears with a moving `last_served_at` and disappears within the TTL after it stops — with the client sending no progress reports.
### Phase 2 — Kill switch
- [ ] Create `internal/streamrevoke` store: interface + in-memory backend + Redis backend (SCAN warm-up, pub/sub push, ≤60s poll, in-memory hot-path cache) + tests.
- [ ] Add durable `revoked_stream_sessions` migration and the Postgres mirror + startup repopulation.
- [ ] Wire the store into edges (`SetRevocationStore` on proxy + transcode Server, `StartSync`) and central (reuse `apiRedisClient`, in-memory fallback).
- [ ] Enforce on the edge hot path: local `IsRevoked` check per segment (refuse + close); periodic re-check + abort for direct-play/remux long pours.
- [ ] Ensure the transcode node does NOT self-reconstruct a revoked session (`reconstructFromToken` consults the store).
- [ ] Extend `OnUserSessionsRevoked` to revoke `user:{uid}`; make admin `terminate` revoke `sess:{sid}` (keep `stop` cooperative).
- [ ] Add config keys (`auth.stream_revocation_poll`, `auth.stream_revocation_ttl`) via the settings pipeline.
- [ ] `go build ./... && go vet ./...`; manual check: revoke a live session → it stops within ~one poll interval and a reconnect with the same token is refused (stays dead); revoke a user → all their streams stop.
### Phase 3 — Async brain
- [ ] Create the enforcer loop + pluggable `Rule` interface; implement `OverCapRule` + stub `RestreamHeuristicRule`; fail-open on limit-provider errors.
- [ ] Start the enforcer on central; wire snapshot + limit provider + revocation store.
- [ ] Structured logging/metrics for every kill; optional admin "recent kills" endpoint.
- [ ] `enforcer_test.go` deterministic tests.
- [ ] `go build ./... && go vet ./...`; manual check: start N+1 concurrent streams for a capped user → within ~120s the over-cap stream(s) are killed and stay dead; under-cap users are untouched.
---
## Testing
- Unit: `internal/streammonitor` (aggregation/aging), `internal/streamrevoke` (cache/TTL/pub-sub/poll/durability), `internal/playback` enforcer (victim selection, fail-open).
- Integration (manual, needs `docker compose up -d postgres redis` + a real client): verify authoritative monitoring with a client that sends no progress; verify kill-within-poll-interval + stays-dead for transcode, direct-play, and remux; verify multi-node (central issues kill, edge enforces) via the shared Redis.
- Regression: existing playback is unaffected when nothing is revoked (the only hot-path addition is a local set lookup that returns false).
## Risks / follow-ups
- **Enforcement latency (~≤120s)** is intentional and accepted; document it so operators expect a short window before a kill takes effect.
- **Direct-play long GET** requires a periodic in-serve revocation check + connection cut; a naive single-GET client with no resume may see an error rather than a clean stop — acceptable for a killed (abusive/over-cap) stream.
- **Redis flush/restart** would drop in-flight revocations unless the Postgres mirror is in place; keep the mirror for durability (reliability-first).
- **Integrated single-node without Redis** uses the in-memory revocation store and in-process monitoring; the durable mirror still applies. No cross-node concern there.
- **Abuse heuristics** (`RestreamHeuristicRule`) ship disabled; tune against real traffic before enabling to avoid false kills.
- **Bytes attribution** on the edge is best-effort for monitoring/heuristics; it must never gate the hot path.
## As-built deltas from this plan
The shipped implementation follows this plan; a few names/shapes differ (the coverage matrix is authoritative for the current state):
- The durable mirror landed as table `stream_revocations(kind, id, reason, revoked_at, expires_at)` (PK `(kind,id)`) via `internal/streamrevoke/durable_postgres.go`, and is **wired central-side and non-optional** — warmed on boot (bounded retry) and re-armed into Redis on the poll tick so multi-node edges reconverge after a Redis flush.
- Enforcement is centralized in two shared helpers `streamrevoke.Store.Refuse` (403) and `WatchAndCut` (in-flight connection cut) used by every serve surface, rather than per-handler ad-hoc checks.
- **Expiry is monotonic on every write.** `applyLocal` and the durable `Upsert` keep whichever copy expires later (`GREATEST`), so the async enforcer's short 5m self-heal TTL can never shorten a longer admin `RevokeSession` on the same session key (which would otherwise reopen the restart-resurrection window the durable mirror closes).
- **In-flight cut required a middleware fix.** `WatchAndCut` uses `http.NewResponseController(w).SetWriteDeadline`, which walks `Unwrap()`. The native and jellycompat middleware `ResponseWriter` wrappers lacked `Unwrap()`, so the integrated cut was a guaranteed no-op until `Unwrap()` was added to `statusWriter`, `requestStatusWriter`, `loggingResponseWriter`, `compatImageProxyTagResponseWriter`, and `debugResponseWriter`.
- **Ownership carry-forward in the monitor.** `streammonitor.mergeStreams` recovers a resolved `UserID`/`ProfileID`/`MediaFileID` (and backfills route/client) across the same-session dedupe, so the transcode node's ownerless start record can't bucket a stream under user 0 and exempt it from the cap.
- **Existence vs timing, made explicit.** Monitoring separates *existence* (server-observed on every path — the transport-count shield integrated, byte-observed at the edge — so a hidden stream that withholds progress is still counted) from *timing* (client progress is trusted, native fully). The transcode serve paths (native `playback.go` and compat `HandleHLSSegment`) gained a per-segment transport marker so a slow single-segment drain can't be reaped mid-serve and the compat HLS path no longer depends on the client's progress POST for liveness. A buffer-ahead idle gap >45s is a remaining reaper follow-up (VERIFY-4).
- **First-class monitoring fields.** Added a `Route` (native/jellycompat) dimension — seeded from `Session.Origin` and carried to edges via a token `Origin` claim — plus client IP/name and position, all surfaced through `SessionInfo`/`LiveStream`. The transcode node's start record is now owner+route+client attributed via `TranscodeStartRequest`. The admin session list unions the in-process integrated sessions (`LiveLocalSessions`) so single-node deployments aren't Redis-blind. Downloads remain an explicit cap exemption (separate quota).
- `auth.stream_revocation_poll` / `auth.stream_revocation_ttl` operator config keys are not yet wired (defaults 60s / 24h are hardcoded) — remaining follow-up.
### Later hardening pass (post-plan)
A follow-up audit against the two goals — *no invisible streams* and *every stream
killable* — found four more gaps this plan did not anticipate. All are shipped; the
coverage matrix remains authoritative.
- **Integrated byte accounting.** `BytesServed` was edge-only, so a single-node install
reported `bytes_served: 0` for every stream, and integrated `LastServedAt` was mapped
from `LastActivityAt` — which a client progress report advances, letting a client
influence which of its own streams the enforcer trims. `Session` now carries
`BytesServed` and a distinct `LastServedAt` advanced only by server-observed events, fed
by one shared `playback.SessionMeteredWriter` wired at every integrated pour (it forwards
`io.ReaderFrom` to keep sendfile and implements `Unwrap()` to keep the cut reaching the
socket).
- **VERIFY-4 closed.** An unpaused transcode now gets a bounded 10m grace measured from
that server-observed clock, plus a 180s idle window for transcode records at the edge
tracker. This was chosen over an ffmpeg liveness probe, which sees only *local* processes
and reports false once a copy-mode encode completes ahead of playback. Paused sessions
keep their load-bearing 30m grace.
- **The kill list gained an operator surface.** `Store.List()` existed but was reachable
from nowhere. Added `GET`/`POST`/`DELETE /api/v1/admin/streams/revocations` plus
`Store.Unrevoke` — the first way to undo a kill, since expiry is deliberately monotonic.
Unrevoke is tombstone-guarded so a concurrent poll reconcile cannot resurrect the row,
and `maintain` now shares the revoke/unrevoke lock so its durable self-heal cannot
re-`Upsert` a just-deleted kill.
- **The Audiobookshelf surface was outside the design entirely** — three byte-serving
routes, none consulting the kill switch, one of them (`/feed/{slug}/file/{ino}`)
unauthenticated. All three now enforce revocation with their real credential-issue time
(ABS bearer JWTs are stateless and survive `OnUserSessionsRevoked`, so request-entry time
would have meant a user kill never refused a later ABS request).
Still deferred, and deliberately so: multi-replica enforcement (VERIFY-3), the
transcode-node ghost-record sweep, and a shared quota for the three download-class routes.
### Post-plan hardening round 2 — 2026-07-30 (serve-path batch)
A two-model adversarial review found six gaps the branch's own tests did not catch
(GAP-10..GAP-15, all recorded in the companion coverage matrices). Four are now closed;
the fixes changed two things this plan asserted:
- **"Every byte-serving path is observed" was false**, and is now closer to true.
Ebook/comic/PDF reading had no meter, no transfer record, no `Refuse` and no in-flight
cut (GAP-11); it now has all four, cap-exempt per decision A4. Subtitle pours on all
three surfaces were entry-gated only and now carry a transport span, a meter and a
watcher. The no-proxy remote transcode hop now meters its media bodies. It is still not
literally *every* path: transfer-registry saturation (GAP-14) can still blind
download-class monitoring, and edge transcode liveness is still request-observed
(GAP-15).
- **"The in-flight cut works" was false on the ABS surface and racy elsewhere.** ABS's
`statusRecorder` lacked `Unwrap()`, so `SetWriteDeadline` returned `ErrNotSupported`
and the cut was a silent no-op on every ABS route (GAP-10) — and the existing test
passed throughout because it called handlers directly, bypassing the middleware that
broke it. Fixing that alone was **not sufficient**: the same `Unwrap()` makes
`RollingDeadlineWriter.bump()` start working, and `bump()` pushed the cut deadline back
out (GAP-12). Both are fixed, the latter with a request-context `CutLatch`, because the
rolling writer is built *inside* the serve helpers and wraps the writer the watcher
holds — so it cannot be reached by `Unwrap()`, which walks the other way.
**A bound this plan never stated:** the in-flight watcher polls, so a cut lands within
one interval (**5s in production**), not instantly. Every "cuts the stream" claim in this
plan should be read that way.
Two of this plan's other claims remain **not yet true** and are tracked to later batches:
an over-cap kill still reopens after 5m (decision A1), and monitoring still consults
client-progress `LastActivityAt` as a liveness fallback, so "never trusts client
progress" awaits decision A5.
## AI-use disclosure
This plan was drafted with AI assistance (Claude), based on a read-only audit of the current codebase. No behavior was changed by writing it. The Status banner and As-built deltas section were added after implementation.
The 2026-07-30 hardening round was a cross-model relay: Claude Opus 5 (`claude-opus-5[1m]`) planned, reconciled and reviewed; Codex `gpt-5.6-sol` at medium effort adversarially reviewed the plan, implemented it, and took one remediation round. Every design fork was decided by the maintainer. The adversarial findings on both sides and their resolution are recorded in [`stream-abuse-matrix.md`](../../architecture/stream-abuse-matrix.md) under "AI-use disclosure"; commands assume the repository root is the cwd.
@@ -0,0 +1,308 @@
# Abuse Cold-Enforcer — Architecture Decision & Plan
> **Branch:** `abuse-cold-enforcer` (based on `origin/main`).
> **Name meaning:** "cold" = every decision stays **off the hot path**; "abuse" =
> the scope widens from concurrency-only (the `feat/sauron-async-enforcer` branch)
> to the full set of abuse classes catalogued in
> [`../../architecture/stream-abuse-matrix.md`](../../architecture/stream-abuse-matrix.md).
> Commands assume the repository root is the cwd.
>
> **Inputs learned from:** the `feat/sauron-async-enforcer` branch (kill-switch
> implementation), its plan
> [`2026-07-04-stream-monitoring-and-kill-switch.md`](2026-07-04-stream-monitoring-and-kill-switch.md),
> its coverage matrix
> [`../../architecture/playback-paths-monitoring-kill-matrix.md`](../../architecture/playback-paths-monitoring-kill-matrix.md),
> and the adversarial assessment
> [`../../architecture/stream-abuse-matrix.md`](../../architecture/stream-abuse-matrix.md).
---
> **DECISION UPDATE (2026-07-07, supersedes §2.3 and P2 below).** After adversarial
> review, the two-tier lease was **dropped**. Everything a lease provides is already
> latent in the deny-list pipeline: enforcer revocations carry a short self-healing
> TTL and are re-asserted every tick (reversibility + continuous re-evaluation), and
> the one genuine lease benefit — surviving a sustained outage — is captured by
> **reason-scoped revocation TTLs** (deterministic violations like a byte-budget
> breach write a durable revocation until the period rolls; over-cap keeps the short
> self-healing TTL; admin kills stay 24h + durable). Fail-closed enforcement was
> judged wrong for its only distinctive feeder (untuned heuristics — false positive +
> Redis blip would kill legitimate playback), and a second enforcement mechanism with
> a second failure posture was judged more fragile than the coverage it buys (one
> matrix row, B13, during a >5m Redis outage). **The architecture is the single
> pipeline: `streammonitor` → enforcer tick → `streamrevoke` → in-memory `IsRevoked`,
> with policy checks for *new* activity at synchronous admission.** Pillars 2–4 and
> phases P0/P1/P3/P4 stand unchanged; P1's enforcement action becomes a durable
> revocation instead of a Tier-1 flag.
## 1. Non-negotiables (inherited, do not regress)
1. **Off the hot path.** The per-segment / per-byte serve path may add at most one
local in-memory lookup. No per-request DB or Redis on the serve path. (This is
the "cold" in cold-enforcer.)
2. **No client-facing protocol change.** Clients (Jellyfin, Infuse, Silo native)
keep echoing the same stable stream token. Any short-lived state lives
**server-side**, keyed by the token's `sid`/`uid`. This constraint is what
killed the naïve short-ticket idea in the prior plan; §3 shows how to keep the
ticket *benefits* without violating it.
3. **Reliability first / predictable under failure.** A brain/Redis hiccup must not
silently break legitimate playback. Failure posture must be a tunable knob, not
an accident.
4. **Reuse, don't rewrite.** The sauron branch's *authoritative, byte-observed
monitoring* (`streammonitor`, `nodesessions`) and its *durable, reconnect-proof
revocation* (`streamrevoke`) are good primitives. Keep them; change how the
*decision* is made and *widen what is decided*.
---
## 2. The core question: kill-list (deny) vs short-lived tickets (allow)
The prior branch chose a **deny-list**: streams run by default; the async brain
observes abuse, writes a revocation, and the edge refuses on an in-memory
set-membership check. The rejected alternative was **short-lived tickets**: the
credential itself expires quickly and must be continually re-minted, so an
unauthorized stream dies when its ticket lapses.
### 2.1 Why the branch rejected tickets — and why that reasoning is incomplete
The plan rejected tickets because Silo's `master.m3u8` is **VOD, fetched once**, so
a short ticket embedded in segment URLs would expire mid-movie and 401 the rest of
the stream; and flipping the global token TTL is a break-all-playback change.
Both objections are about putting the short TTL **in the client's credential**.
They do **not** apply if the short-lived state lives **server-side**. The client
keeps its stable 24h token; the *server* holds a short-lived **lease** keyed by that
token's `sid`. The edge hot path checks the local lease, not a client-presented
ticket. This is a "ticket" in every property that matters, minted server-to-server,
requiring zero client cooperation — which is exactly the gap the prior analysis
missed.
### 2.2 Deny-list vs lease (server-side ticket) — honest trade
| Property | Deny-list (current branch) | Lease / server-side ticket |
|---|---|---|
| Default state | **allow** (runs unless revoked) | **deny** (runs only while leased) |
| Hot-path cost | in-memory set lookup | in-memory `sid→expiry` lookup (identical class) |
| To stop a stream | write a revocation, propagate | **stop renewing** (or expire) the lease |
| Stop latency | detect → decide → propagate (~120s) | ≤ one lease TTL, **same path for every reason** |
| Maintenance load | write **only on kill** (rare, cheap) | **renew every active stream every TTL** (continuous) |
| Restart-resurrection | **must** bolt on a durable mirror (branch's GAP-4) | **free**: a reconstructed session has no lease until re-granted |
| Failure posture | **fail-open** — brain/Redis down ⇒ new kills don't land, abuse continues, but playback survives | **fail-closed** — brain/Redis down ⇒ leases lapse, abuse stops, but legit playback also stops |
| Policy checkpoint | scattered: each rule writes its own kill | **one** checkpoint: the renewal tick evaluates cap + ban + byte-budget + throughput at once |
The two are duals. Deny-list optimizes **availability** (fail-open, cheap,
already-built) and is *reactive*. Lease optimizes **abuse control** (fail-closed,
self-expiring, restart-safe, one uniform action) and is *preventive* — at the cost
of continuous renewal load and an availability blast-radius if the brain stalls.
### 2.3 Decision: a **two-tier** enforcer (deny by default, lease for suspects)
Neither pure form is right for a media server that must not break legit playback
*and* must reliably stop abuse. Use both, graduated by suspicion:
- **Tier 0 — normal streams: deny-list default (keep the branch as-is).** A stream
runs unless explicitly revoked. Hot path = the existing `IsRevoked` set lookup.
Fail-open. This preserves "playback never breaks because the brain hiccuped" for
the 99% case, honoring *reliability first*.
- **Tier 1 — flagged/suspect streams: promote to lease-required (fail-closed).**
When the cold brain flags a session — over a *soft* cap, anomalous throughput /
distinct-viewer fan-out (re-streaming), or over a rolling byte budget — it
**demotes** that session to lease-required: the edge now additionally requires a
fresh central lease for that `sid`, and the brain grants it only while policy
permits. Stop = simply stop renewing. This gives suspects the lease's good
properties (≤TTL cut, no restart-resurrection, and — crucially — **an outage can
no longer be used to escape an existing flag**, because a lapsed lease fails
closed) while the expensive renewal load is paid *only for the small suspect set*,
not every stream (honoring *performance first*).
- **Immediate admin kill stays deny-list + durable.** "Cut this now" must not wait
≤TTL, so admin terminate/ban writes an immediate revocation and a **durable ban
ledger** (Postgres) so a banned *user* is never re-leased or re-admitted across a
restart. The durable store thus shrinks from "mirror every ephemeral kill" to
"record standing bans + quota ledgers" — a more natural durability boundary.
This is the headline architecture. Everything below is the machinery to feed it and
the adjacent pillars the matrix demands.
**What this reuses from `feat/sauron-async-enforcer`:** `streammonitor` (the
authoritative cross-node snapshot) is the input to the renewal tick unchanged;
`streamrevoke` becomes the Tier-0 + admin path unchanged; the pub/sub + Redis
`silo:sessions:*` transport is reused to carry a central-stamped `lease_until` field
(the lease is one extra field on records the brain already reads). The edge check
flips from `if revoked → refuse` to `if revoked → refuse; else if leaseRequired[sid]
&& now > leaseUntil[sid] → refuse`. Small, additive, off hot path.
---
## 3. The four capability pillars (mapped to the matrix)
The "kill-list vs ticket" decision only governs Pillar 1. The matrix demands three
more, independent of that choice.
### Pillar 1 — Concurrency & policy decision engine (A1–A8, C15)
- **Fix the dormant-enforcer bug first (matrix correction #1).** The sauron
enforcer reads the *raw* `users.max_streams` column, which migrations set to `0`
for standard users (real cap lives in the Default Group). `limit<=0` ⇒ skip, so
the async brain never fires for a normal user. Resolve the **group-merged
effective policy** (`access.EffectivePolicyForUser` → `strictestPositive`) in the
brain's `LimitFunc`, exactly as synchronous admission already does.
- **Keep synchronous admission** (`ErrTooManyStreams`) as the immediate preventive
gate (A1/A2). The cold brain is the cross-node backstop (A7/A8/C15) and now the
lease authority for suspects.
- **Multi-replica (A7, VERIFY-3):** have integrated replicas publish their local
sessions into the shared `silo:sessions:*` picture (or elect a single brain
leader) so the cross-node count is authoritative. The lease grant is the natural
serialization point.
### Pillar 2 — Liveness & anti-evasion (A4, A5)
- **A4 buffer-ahead-then-pause is the one open monitoring hole.** Fix it
model-independently: treat a **running transcode ffmpeg as liveness** (or a
transcode-specific grace ≥ the client's max buffer) so a session that pre-buffers
and pauses fetches >45s is neither reaped nor decounted, and cannot free a slot to
admit another. This touches the sensitive session-reaping path (cf. #279) — do it
behind a flag with tests.
- **A5 progress-withholding is already solved** (existence is byte-observed). Keep;
add a regression test so it stays that way.
### Pillar 3 — Volume/quota accounting (C16, D17–D19) — new
Concurrency caps count *streams*, never *bytes*, so every rip vector walks straight
through them. Add a **rolling per-user byte budget** that spans **both** downloads
and direct-play/stream egress:
- Meter served bytes per `uid` (the edge already meters egress; attribute it per
session — the sauron branch added `BytesServed` to `SessionInfo`, reuse it) and
per download (already tracked).
- A rolling `bytes-per-period` ledger (durable, alongside the ban ledger). When a
user exceeds it, the brain flags them → Tier-1 lease-required → new streams/
downloads are refused and in-flight ones cut (`WatchAndCut`) ≤TTL. This is the
only thing that stops **D19 sequential direct-play ripping** and **C16 token
fan-out that manifests as sustained egress**.
- **Close the compat-download hole (D18, widest gap):** route
`/Items/{id}/Download` through the same `QuantityLimiter` + byte budget as native
downloads (see §4 — today it has *no* quota at all). This is the single
highest-value fix in the whole plan.
### Pillar 4 — Detection heuristics & perimeter (C14, E21–E29) — partly new, partly gap-closing
- **Re-streaming heuristic (C16 fan-out only — C14 is undetectable this way) — build it
(it does not exist; matrix correction #2).** ⚠️ **Superseded by abuse-matrix correction
#9 (2026-07-31): the fingerprints do NOT already flow in usable form.** `ClientIP` is a
single value per session — stamped once at session creation in integrated mode,
overwritten per request at the edge — so concurrent viewers collapse to one address and
no consumer of the existing snapshot can see fan-out. It needs per-request observation
at media-serve time. The rule sketched below is retained for its shape, not its
feasibility claim:
distinct client-IPs per `sid`, sustained throughput vs. media bitrate, distinct
concurrent titles per `uid`. Output = a flag → Tier-1 demotion. Ship **disabled**;
tune against real traffic before enabling (false-kill risk).
- **CPU/transcode exhaustion (E29):** add a **per-node concurrent-transcode cap**
at the node's `handleStart` admission (today `reconstructSem` guards only the
restart path; fresh starts are unbounded). Wire `nodepool.MaxJobs` sensible
defaults.
- **API-flood perimeter (E25–E28):** extend the existing `internal/ratelimit` to
the **jellycompat surface** and `/auth/refresh` (both currently unthrottled), and
add a connection cap (`netutil.LimitListener` / per-IP). These live in existing
subsystems — gap-closing, not new architecture.
- **Library-list load (E21–E24):** out of scope for the stream enforcer, but the
cold philosophy applies — add per-client browse-cost accounting + a cache/
singleflight on the *general* paged browse (today only the Latest/hub rails are
cached). Track separately; note here so it is not forgotten.
---
## 4. Download-quota verification (answering the explicit question)
**Claim under test:** the admin "max concurrent downloads per user" / "max downloads
per period" / "period duration" settings let an operator cap e.g. **20 downloads per
30 days**, and that cap actually holds.
**Result: it HOLDS for the native download routes, and is BYPASSED on the compat
route.**
- Config keys exist and are operator-settable: `download.max_concurrent_per_user`
(default 3), `download.max_per_period` (default **0 = unlimited**),
`download.period_duration` (default 24h) — `internal/config/db_loader.go:545–553`.
Setting `max_per_period=20`, `period_duration=720h` gives "20 per 30 days".
- The period check is enforced **at download-record creation** at all four creation
sites (single, direct, series batch, season batch) —
`internal/downloads/service.go:356,427,597,689` call `limiter.Check(...)` *before*
inserting rows.
- **Crucially, the period count is not defeatable by completing downloads.**
`CountByUserSince` counts every row with `created_at >= now-period AND status NOT
IN ('cancelled','failed')` (`internal/downloads/repo.go:190–200`) — i.e.
**completed downloads still consume the period quota**. Only the *concurrent*
gate (`CountActiveByUser`, `status IN ('queued','downloading','preparing')`,
`repo.go:176–186`) frees on completion. So a `create→complete→create` loop can
keep 3-in-flight forever but **cannot** exceed 20-in-30-days. Cancelled/failed are
excluded so transient failures don't burn quota — correct.
**Caveats the operator must know:**
1. **Compat `/Items/{id}/Download` bypasses all of it.** `HandleDownload`
(`internal/jellycompat/streams.go`) creates no download row and never calls
`QuantityLimiter` (grep confirms zero references). An Infuse-style client
downloads unlimited full files regardless of the 20/30d setting. → Pillar 2/§3
fix: route it through the same limiter + byte budget.
2. **The period quota counts downloads, not bytes.** "20 downloads" of 20 different
4K remuxes is ~1 TB; the quota bounds *count*, not *volume*. The Pillar-3 byte
budget is what bounds volume.
3. **Streaming-based ripping is unaffected.** Sequential direct-play (D19) creates
stream sessions, not download rows, so the download quota never sees it.
4. **Minor TOCTOU:** two concurrent `Check` calls can both pass before either
inserts; the batch path mitigates via `batchSize`, but a burst of single
creates could momentarily overshoot by the concurrency of the burst. Low
severity; note for a follow-up (advisory lock or insert-then-verify).
**Bottom line for the operator:** yes, 20/30d works today for the native app's
downloads and can't be gamed by finishing downloads — but it does **not** cover
Infuse/compat downloads or streaming rips, and it counts files not bytes. The plan
closes those three gaps in Pillars 2–3.
---
## 5. Matrix coverage after this plan
| Matrix rows | Today (sauron branch) | After this plan |
|---|---|---|
| A1–A3 over-cap | ✅ admission (once bug #1 fixed) | ✅ + reliable cross-node backstop |
| A4 buffer-ahead | ❌ invisible window | ✅ ffmpeg-liveness (Pillar 2) |
| A5 progress-withhold | ✅ | ✅ (+ regression test) |
| A6–A8 churn / multi-replica / dormant enforcer | ⚠️ | ✅ bug-fix + replica publish + lease-for-suspects |
| B9–B12 admin kill/ban/restart | ✅ | ✅ (durable ban ledger, unchanged) |
| B13 Redis outage | ⚠️ fail-open (abuser escapes) | ✅ flagged suspects fail-closed; legit fail-open |
| C14 under-cap re-stream | ❌ | ⚠️→✅ heuristic (Pillar 4, ship disabled) |
| C15 over-cap re-stream | ✅ | ✅ + labeled |
| C16 token hoard/fan-out | ❌ | ⚠️ caught via byte budget when it manifests as egress |
| D17 native rip | ⚠️ count-only | ✅ byte budget (Pillar 3) |
| D18 compat rip | ❌ no quota | ✅ route through quota + budget |
| D19 sequential direct-play rip | ❌ | ✅ byte budget |
| D20 admin stop rip | ⚠️ WatchAndCut | ✅ (unchanged) |
| E21–E24 library load | ❌ | ⚠️ browse-cost accounting + cache (tracked separately) |
| E25–E28 API floods | ⚠️ gaps | ✅ extend ratelimit to compat/`/auth/refresh` + conn cap |
| E29 CPU exhaustion | ❌ | ✅ per-node transcode cap |
---
## 6. Phased plan
1. **P0 — correctness first (small, high value):** fix the dormant-enforcer limit
source (matrix #1); close the compat-download quota hole (D18); add the per-node
transcode cap (E29). No new architecture — all three are bounded fixes with
outsized payoff.
2. **P1 — byte budget (Pillar 3):** rolling per-user bytes-per-period ledger across
downloads + stream egress; wire into a flag. Bounds all rip vectors.
3. **P2 — two-tier lease (Pillar 1):** add the `lease_until` field + Tier-1
demotion for flagged sessions; fail-closed for suspects, fail-open for normal.
4. **P3 — liveness (Pillar 2):** ffmpeg-as-liveness for A4, behind a flag + tests.
5. **P4 — detection (Pillar 4):** re-streaming heuristic (ship disabled) + perimeter
ratelimit gap-closing + library-browse cost accounting.
Each phase is independently shippable and independently valuable; P0 alone closes
the three worst holes.
## AI-use disclosure
Drafted with AI assistance (Claude) from a read-only audit of both branches and the
four referenced docs, plus targeted verification of the download-quota enforcement
path. No code behavior changed by writing this plan.
@@ -1,366 +0,0 @@
# 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.
@@ -1,510 +0,0 @@
# User-Facing Device Settings Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> Commands assume the repository root is the cwd.
**Goal:** Let a person see and change the device settings for every device they watch on, from whichever device they are holding — and let the household parent do the same for everyone on the account.
**Architecture:** No new storage and no schema change. `user_devices` and `user_setting_values` are already keyed `(user_id, profile_id, device_id, …)` and both list queries are already account-wide, so the work is authorization plus routes plus UI. Two identity widenings on the existing canonical settings API, each behind a guard: a caller may name a `device_id` other than the request's own (checked against `user_devices`), and a household parent may name a `profile_id` other than their own (checked by the existing `canManageHouseholdProfiles`). One new self-service device registry endpoint, deliberately profile-filtered by default. One new settings page reusing `SettingsGroup`/`SettingRow`.
**Tech Stack:** Go, `net/http` handler tests, `internal/userstore/storetest` conformance suite, React 19 + react-router v7 + TanStack Query, Vitest + Testing Library, shadcn/ui primitives.
**Design source:** `docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md`. Mockups were reviewed out-of-band; the shipping shape is the "B2 + B3" pair — a searchable device list with an editable detail pane, plus a household scope switch for the primary profile.
## Global Constraints
- **Additive only.** No existing response field is renamed, retyped, or removed, and no status code is repurposed. New behavior arrives as new endpoints or new optional query parameters. See `CLAUDE.md` "v1 API rules".
- **The header stays the default.** When `device_id` / `profile_id` are absent from the query, every existing route must behave exactly as it does today. Existing clients must not need a change.
- **No new hand-written setting metadata.** Labels, descriptions, controls, options and bounds come from `contracts/settings/v1/manifest.json` via the generated `web/src/lib/settingsContract.ts`. A per-key table beside the generated one is exactly the drift the contract exists to remove.
- **Never render raw setting keys** in user-facing UI.
- **Scope wording is mandated** by the design spec: "this device, for your profile only". Do not invent alternatives such as "global", "default", or a bare "this device".
- **Restrictions are not preferences.** Policy caps are explained with the permitted value and the reason; they are never rendered as a disabled control with no explanation, and this screen never authors a restriction.
- Prove each authorization regression RED before writing production code.
- Do not edit this plan file while implementing it.
---
## Phase 1 — Server: identity widening and the device registry
### Task 1: Reject a device the caller does not own
`completeIdentity` validates an identity's *shape* but never that a `profile_device` identity names a device belonging to the caller. That is safe today only because `DeviceID` is taken from the request's own header. Task 2 removes that guarantee, so the check lands first.
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/userstore/store.go`
- Modify: `internal/userstore/pgstore/settings.go`
- Modify: `internal/userdb/settings.go` (the per-user SQLite backend)
- Test: `internal/api/handlers/settings_values_test.go`
- Test: `internal/userstore/storetest/settingvalues.go`
**Interfaces:**
- Produces: `DeviceRegistry.DeviceExists(ctx, profileID, deviceID string) (bool, error)` — a targeted existence check rather than a full `ListDevices` scan on every write.
- Produces: a device-ownership guard invoked from `completeIdentity` for `ScopeProfileDevice`.
- [ ] **Step 1: Write the failing ownership test**
Add `TestSetValue_RejectsDeviceNotOwnedByCaller` to `settings_values_test.go`: register device `dev-a` for the caller, then `PUT /settings/values/player.hdr_enabled?scope=profile_device&device_id=dev-someone-else`. Expect `404` with error code `not_found` — not `403`, which would confirm the device id exists.
Because Task 2 has not landed, the query parameter is ignored and the write silently succeeds against the header device. Assert on the *stored* row, so the test fails for the right reason:
```go
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Fatalf("wrote a row for device %q; want no write", got)
}
```
- [ ] **Step 2: Run the test and verify RED**
```bash
go test ./internal/api/handlers/ -run TestSetValue_RejectsDeviceNotOwnedByCaller -v
```
- [ ] **Step 3: Add `DeviceExists` to the store interface and both backends**
Postgres: `SELECT EXISTS(SELECT 1 FROM user_devices WHERE user_id = $1 AND profile_id = $2 AND device_id = $3)`. SQLite: the same without `user_id`, matching the existing `ListDevices` asymmetry (one DB per user). Add the case to the shared conformance suite in `internal/userstore/storetest/settingvalues.go` so both backends are held to it.
- [ ] **Step 4: Enforce it in `completeIdentity`**
For `ScopeProfileDevice`, when the device id did **not** come from the request header, verify it exists for `(profileID, deviceID)`. A device that is unknown returns 404 `not_found`. Registering-on-write stays the behavior for the caller's own header device, so a brand-new device can still store its first value.
- [ ] **Step 5: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ ./internal/userstore/... -run 'Device|SettingValue' -v
```
---
### Task 2: Accept an explicit `device_id` on the self-service settings routes
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Test: `internal/api/handlers/settings_values_test.go`
**Interfaces:**
- Consumes: optional `device_id` query parameter on `GET|PUT|DELETE /settings/values/{key}` and `GET /settings/values` when `scope=profile_device`.
- Produces: `identityForSessionKey` resolving `DeviceID` from the query when present, else from `X-Silo-Device-Id` exactly as today.
- [ ] **Step 1: Write the failing tests**
Three cases in `settings_values_test.go`:
- `TestSetValue_WritesNamedDevice` — register `dev-b`, `PUT …?scope=profile_device&device_id=dev-b` from a request whose header is `dev-a`; assert the stored row is on `dev-b` and `dev-a` has none.
- `TestGetValues_ReadsNamedDevice` — same shape for the read path.
- `TestSetValue_FallsBackToHeaderDevice` — no `device_id` in the query writes the header device. This is the regression guard for every existing client.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'NamedDevice|FallsBackToHeaderDevice' -v
```
- [ ] **Step 3: Implement**
In `identityForSessionKey`, prefer a non-empty `device_id` from the query and fall through to `deviceMetadataFromRequest(r).DeviceID`. Update the comment at the profile assignment so it still describes reality: the profile remains session-derived here; only the device may be named. Keep the existing 400 when neither source yields a device id.
Do **not** call `registerWritingDevice` for a named device — registration is a statement that *this* device is in use, and a remote write is not that.
- [ ] **Step 4: Run and verify GREEN, then run the whole handler package**
```bash
go test ./internal/api/handlers/ -v
```
---
### Task 3: `GET /api/v1/devices` — list your own devices
**Files:**
- Create: `internal/api/handlers/devices.go`
- Create: `internal/api/handlers/devices_test.go`
- Modify: `internal/api/router.go`
**Interfaces:**
- Produces: `GET /api/v1/devices` → `{"devices":[{device_id, device_name, device_platform, last_seen_at, profile_id, profile_name, is_current_device, changed_count}]}`.
- Consumes: `DeviceRegistry.ListDevices`, `ListAllSettingValues`, `X-Silo-Device-Id`.
**The trap this task exists to avoid:** `ListDevices` is account-wide in *both* backends — `WHERE user_id = $1` in `internal/userstore/pgstore/settings.go:115`, and no `WHERE` clause at all in `internal/userdb/settings.go:112` because there is one DB per user. `profile_id` is a selected column, never a predicate. A naive passthrough would show every household member's devices to everyone. The handler must filter.
- [ ] **Step 1: Write the failing tests**
- `TestListDevices_FiltersToCallingProfile` — seed devices for profile A and profile B, call as A, assert only A's are returned. **This is the security test; write it first.**
- `TestListDevices_CountsChangedSettings` — `changed_count` equals the number of `profile_device` rows for that `(profile, device)`.
- `TestListDevices_MarksCurrentDevice` — the device matching `X-Silo-Device-Id` has `is_current_device: true`.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run TestListDevices -v
```
- [ ] **Step 3: Implement the handler**
Filter `ListDevices` output to `apimw.GetProfileID(ctx)`. Derive `changed_count` from `ListAllSettingValues` filtered to `scope == profile_device` and the same profile — one store round trip, not one per device. `profile_name` comes from the existing `listProfileNamesByID` helper pattern in `internal/api/handlers/admin.go`.
- [ ] **Step 4: Register the route**
In `internal/api/router.go`, inside the authenticated group, `r.With(apimw.RequireProfile).Get("/devices", devicesHandler.HandleListDevices)`. Place it away from the `/devices/push/apple` line so the two device namespaces stay visibly distinct.
- [ ] **Step 5: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ -run TestListDevices -v
```
---
### Task 4: Forget a device, and bulk-clear one device
**Files:**
- Modify: `internal/api/handlers/devices.go`
- Modify: `internal/api/router.go`
- Modify: `internal/userstore/store.go` (if a targeted delete is missing)
- Test: `internal/api/handlers/devices_test.go`
**Interfaces:**
- Produces: `DELETE /api/v1/devices/{device_id}` — forget: clears settings **and** the registry row.
- Produces: `DELETE /api/v1/devices/{device_id}/settings` — clear overrides, keep the device.
The design spec requires Forget device (`…-design.md:416`) and lists it as outstanding (`:1202`). `DeleteAllDeviceSettings` exists and clears both storage generations but is reachable only through profile deletion today. Bulk clear also fixes the admin screen's 30-sequential-DELETE loop in `web/src/hooks/queries/admin/users.ts:451`.
- [ ] **Step 1: Write failing tests**
- `TestForgetDevice_RemovesSettingsAndRegistryRow`
- `TestForgetDevice_RejectsOtherProfilesDevice` → 404
- `TestClearDeviceSettings_KeepsRegistryRow`
- `TestForgetDevice_IsIdempotent` — a second call returns 204, not 500
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'ForgetDevice|ClearDeviceSettings' -v
```
- [ ] **Step 3: Implement both routes**
Reuse `DeleteSettingValuesForDevice` (`internal/userstore/store.go:216`) and `DeleteAllDeviceSettings`. Publish `user_settings.changed` once per cleared key so other devices invalidate — or once for the device if a batch event shape is added; do not skip the event.
- [ ] **Step 4: Run and verify GREEN**
```bash
go test ./internal/api/handlers/ ./internal/userstore/... -v
```
---
## Phase 2 — Server: the household tier
### Task 5: Extract the household-parent guard
`canManageHouseholdProfiles` (`internal/api/handlers/profiles.go:146`) already encodes exactly the right rule — server admin, or an `is_primary` active profile, and when that profile has a PIN a verified `X-Profile-Token` so sending only `X-Profile-Id` cannot bypass the profile lock. It is a method on `ProfileHandler`, so `SettingValuesHandler` cannot call it.
**Files:**
- Create: `internal/api/handlers/household.go`
- Modify: `internal/api/handlers/profiles.go`
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/router.go`
- Test: `internal/api/handlers/household_test.go`
**Interfaces:**
- Produces: `canManageHousehold(r *http.Request, store userstore.UserStore, tokens ProfileTokenValidator) (bool, error)` — a package-level function.
- `ProfileHandler.canManageHouseholdProfiles` becomes a thin wrapper so its four existing call sites and their behavior are untouched.
- `SettingValuesHandler` gains a `ProfileTokens` field, wired in `internal/api/router.go` next to `profileHandler.ProfileTokens = profileTokenService` (`router.go:812`).
- [ ] **Step 1: Characterization tests before moving anything**
In `household_test.go`, cover: admin → true; primary without PIN → true; primary with PIN and no token → `access.ErrProfileUnverified`; primary with PIN and valid token → true; non-primary → false; no active profile → false. Run them against the *existing* method first so the extraction is provably behavior-preserving.
- [ ] **Step 2: Extract, then re-run**
Move the body to the package-level function; leave the method delegating. Run the full profiles suite — those four call sites are the regression surface:
```bash
go test ./internal/api/handlers/ -run 'Profile|Household' -v
```
- [ ] **Step 3: Wire `ProfileTokens` into `SettingValuesHandler`**
Nil `ProfileTokens` must mean "no household widening", never "allow" — assert that in a test.
---
### Task 6: Accept an explicit `profile_id` for the household parent
**Files:**
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/handlers/devices.go`
- Test: `internal/api/handlers/settings_values_test.go`
- Test: `internal/api/handlers/devices_test.go`
**Interfaces:**
- Consumes: optional `profile_id` query parameter on the settings-value routes and on `GET /api/v1/devices`.
- Produces: identity resolution that permits a non-own `profile_id` only when `canManageHousehold` passes.
- [ ] **Step 1: Write the three refusal tests first**
These are the security surface. All three must be RED before any production code:
- `TestSetValue_NonPrimaryCannotNameSiblingProfile` → 403
- `TestSetValue_PrimaryWithUnverifiedPINCannotNameSibling` → 403, code `forbidden`, message naming PIN verification
- `TestSetValue_ProfileFromAnotherAccountIsNotFound` → 404
Then the positive cases: `TestSetValue_PrimaryWritesSiblingProfileDeviceSetting`, `TestListDevices_PrimarySeesHouseholdWhenRequested`.
- [ ] **Step 2: Run and verify RED**
```bash
go test ./internal/api/handlers/ -run 'NameSibling|AnotherAccount|PrimaryWrites|PrimarySees' -v
```
- [ ] **Step 3: Implement**
In `identityForSessionKey`: when `profile_id` is present and differs from the session profile, require `canManageHousehold`; on failure return 403 without disclosing whether the profile exists. Then resolve the profile through the caller's **own** store, exactly as `internal/access/resolver.go:73-86` does — a profile from another account is simply absent, which yields the 404 and preserves the cross-account boundary for free.
For `GET /api/v1/devices`, add `?scope=household` (default: own profile only). Do not make household the default; the plain screen must stay private by construction.
- [ ] **Step 4: Run and verify GREEN, then the full package**
```bash
go test ./internal/api/handlers/ -v
```
**Record in the PR:** this widening does not create a new capability. The primary profile can already rewrite a sibling's canonical settings rows through `PUT /profiles/{id}` (`internal/api/handlers/profiles_settings_sync.go:218-231`), and `GET /profiles` (`profiles.go:263`) already returns every sibling's resolved preferences to any profile with no gate. All profiles share one login session, so `X-Profile-Id` is self-asserted for PIN-less profiles — stated in-repo at `internal/api/middleware/auth.go:180-184`. This task replaces an unlabelled path with a guarded, audited one.
---
### Task 7: Audit cross-profile and device settings mutations
`internal/activitylog` is only an HTTP request-log middleware mounted globally before auth (`internal/api/router.go:236-239`); no handler writes to it. Its entries carry method, path pattern, status, user and session but **no profile id and no body**, so a settings write is indistinguishable from any other `PUT`. Tolerable while every write is your own; not once one profile can change another's. The settings spec already requires admin clear/reset to be audited.
**Files:**
- Modify: `internal/activitylog/` (new entry type or a settings-audit sink)
- Modify: `internal/api/handlers/settings_values.go`
- Modify: `internal/api/handlers/settings_values_admin.go`
- Modify: `internal/api/handlers/devices.go`
- Test: `internal/api/handlers/settings_values_test.go`
- [ ] **Step 1: Write failing tests**
`TestSetValue_AuditsCrossProfileWrite` asserts an entry recording actor profile, target profile, device, key, and action. `TestSetValue_DoesNotAuditOwnWrite` keeps ordinary self-service writes out of the audit trail — otherwise volume makes it useless.
- [ ] **Step 2: Run RED, implement, verify GREEN**
Record the *identity* of what changed, never the value: `user_settings.changed` deliberately carries no value because admins receive other accounts' events (`internal/api/handlers/user_settings_events.go:8-13`). The same reasoning applies to a stored audit row. Also audit Forget device and bulk clear.
---
## Phase 3 — Web: the device settings screen
### Task 8: Query hooks for devices and cross-identity settings
**Files:**
- Create: `web/src/hooks/queries/devices.ts`
- Modify: `web/src/hooks/queries/settingValues.ts`
- Modify: `web/src/hooks/queries/keys.ts`
- Modify: `web/src/api/types.ts`
- Test: `web/src/hooks/queries/devices.test.ts`
**Interfaces:**
- Produces: `useMyDevices({ household? })`, `useForgetDevice()`, `useClearDeviceSettings()`.
- Modifies: `SettingIdentity` gains optional `deviceId` and `profileId`; `identityQuery` (`settingValues.ts:60`) serializes them.
- [ ] **Step 1: Extend `SettingIdentity` and `identityQuery`**
Both fields optional, so every existing caller compiles and behaves identically.
- [ ] **Step 2: Fix the cache key — this is a real bug if skipped**
`effectiveSettingsQueryKey` (`settingValues.ts:76`) namespaces by `activeProfileId()`. Reading another profile's or another device's values through the same key would collide with the current device's cache and serve one device's settings as another's. Add `deviceId` and `profileId` to the key, and add a test that two devices' reads occupy distinct entries.
- [ ] **Step 3: Write the device hooks and tests**
Follow the existing `api()` + TanStack Query conventions. Invalidate `[...settingsKeys.all, "values"]` on every mutation, as `useSetSettingValue` does.
```bash
cd web && pnpm vitest run src/hooks/queries/devices.test.ts
```
---
### Task 9: The device list pane
**Files:**
- Create: `web/src/pages/settings/DeviceSettings.tsx`
- Create: `web/src/components/settings/DeviceList.tsx`
- Create: `web/src/components/settings/deviceDisplay.ts`
- Modify: `web/src/pages/SettingsLayout.tsx`
- Modify: `web/src/App.tsx`
- Modify: `web/src/lib/documentTitle.ts`
- Test: `web/src/components/settings/DeviceList.test.tsx`
**Interfaces:**
- Produces: a master-detail layout — searchable list on the left, selected device on the right, stacking to a single column below the `md` breakpoint.
- Produces: `deviceDisplay.ts` — platform icon/label classification and relative-time formatting. Adapt the existing helpers in `web/src/components/admin/deviceOverrides.tsx:40-146` rather than duplicating them; move them here and have the admin page import from the shared module.
- [ ] **Step 1: Add the route and nav entry**
One `NavSection` item under "Account" in `SettingsLayout.tsx` (`NAV_SECTIONS`, from line 52) with `settings: settingIndex(...)` so the entries reach the settings search index, and one `<Route path="devices" …>` in `App.tsx` beside the other settings routes.
- [ ] **Step 2: Build the list with tests**
Rows are fixed height and carry name, last-used, and a changed-count pill; a device with nothing changed shows a dash rather than "0". Group by recency — Using now / This week / Earlier. Search filters by name and platform. Tests: grouping boundaries, the current device is marked, count pill renders a dash at zero, and search matches on platform as well as name.
```bash
cd web && pnpm vitest run src/components/settings/DeviceList.test.tsx
```
---
### Task 10: The device detail pane
**Files:**
- Create: `web/src/components/settings/DeviceSettingGroups.tsx`
- Create: `web/src/lib/deviceSettingGroups.ts`
- Test: `web/src/lib/deviceSettingGroups.test.ts`
- Test: `web/src/components/settings/DeviceSettingGroups.test.tsx`
**Interfaces:**
- Produces: `groupDeviceSettings(keys)` → Picture / Sound / Subtitles / Episodes, derived from each definition's `category` plus a small key→group map for the cases `category` does not separate (`player.*` splits across Picture and Sound).
- Consumes: `ALL_DEVICE_SETTING_KEYS` (`web/src/lib/settingsDisplay.ts:126`) and `RegistrySettingControl` (`web/src/components/settings/RegistrySettingControl.tsx`).
- [ ] **Step 1: Group mapping, with a completeness test**
`TestEveryDeviceKeyIsGrouped` — every key in `ALL_DEVICE_SETTING_KEYS` lands in exactly one group. This is what stops a newly added manifest key from silently vanishing from the UI.
- [ ] **Step 2: Render rows through the shared primitives**
Use `SettingRow` and `SettingsGroup`. No raw keys. "Changed here" badge when a `profile_device` row exists; "Use my setting" clears at `profile_device` — a DELETE, never a copy of the profile value into the device row. Sliders and steppers must round-trip as numbers; do not reuse the admin screen's string round-trip (`web/src/hooks/queries/admin/users.ts:106-145`).
- [ ] **Step 3: Policy-capped rows**
When the effective response carries `constrained_by`, render `permitted_values` only and state the limit and who set it. Never a disabled control with no reason. Test both a capped select and a `locked` constraint.
- [ ] **Step 4: Run**
```bash
cd web && pnpm vitest run src/components/settings/ src/lib/deviceSettingGroups.test.ts
```
---
### Task 11: Remote-device editing and its copy
**Files:**
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
- Test: `web/src/pages/settings/DeviceSettings.test.tsx`
- [ ] **Step 1: Write to the selected device**
Every mutation passes `deviceId` explicitly rather than relying on the header, so selecting a device and editing it writes that device. Test that editing a non-current device sends its id.
- [ ] **Step 2: Scope and sync copy**
Show the mandated scope sentence once per device — "this device, for your profile only" — not per row. For a non-current device, state that it picks the change up next time it is on. `useSettingValuesRealtime` (`settingValues.ts:245`) already invalidates on `user_settings.changed`, so no polling.
- [ ] **Step 3: Forget and bulk clear, with confirmation**
Both are destructive and both name their target: "Clear all N changes on this device", "Forget this device".
---
## Phase 4 — Web: the household view
### Task 12: Household scope switch and person grouping
**Files:**
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Modify: `web/src/components/settings/DeviceList.tsx`
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
**Interfaces:**
- Consumes: `useIsActingAdmin`, `useCurrentProfile`, and `profile.is_primary` — the same rule as `RequirePrimaryOrAdmin` (`web/src/App.tsx:209`) and `isActingAdmin` (`web/src/lib/permissions.ts:19`). Do not write a fourth definition of this predicate.
- Consumes: `GET /profiles` for names, avatars, `is_child`.
- [ ] **Step 1: The switch appears only for the household parent**
Test that a non-primary, non-admin profile never sees it, and that the page still works fully for them in "just mine" mode. The switch is additive; nothing is taken away from anyone.
- [ ] **Step 2: Group devices by person**
Person header with avatar, name, a "You" or "Kid" tag, and a device count; devices nested beneath. When two profiles have registered the same physical TV, say so — "Same TV as yours — separate settings per person" — because that is the single most confusable thing on this screen.
- [ ] **Step 3: Acting-on-behalf copy**
A persistent banner while a sibling's device is selected: "You're changing Robin's settings, not your own." Reset actions name the person: "Use Robin's setting". Test that the banner is absent for one's own devices.
---
### Task 13: Household boundaries in the UI
**Files:**
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
- [ ] **Step 1: Household limits read as limits, and link out**
A value capped by parental controls shows a lock pill naming who set it and links to the profiles screen. This screen never authors a restriction — settings answer "what does this user want", policy answers "what are they allowed to have" (design spec, "Preferences versus restrictions").
- [ ] **Step 2: State the privacy boundary**
A short block: this page shows how Silo is set up per device, not what anyone watched. Viewing history stays private per profile. Test that it renders in household mode.
- [ ] **Step 3: Full check**
```bash
cd web && pnpm run lint && pnpm run format:check && pnpm vitest run
```
---
## Phase 5 — Verification
### Task 14: Cross-cutting checks
- [ ] **Step 1: Full suite**
```bash
make lint
make test
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
make verify-settings-bindings-all
```
Four Go failures (auth, catalog, jellycompat, notifications) pre-exist on some local Postgres provisioning and are not caused by this work — verify against the branch base before chasing one. `make lint` runs `golangci-lint` over the whole tree while CI runs `--new-from-merge-base`, so expect pre-existing findings that CI will not fail on; do not add to them.
- [ ] **Step 2: Browser verification**
Use the `web-ui-testing` skill against a real backend. Capture, for the PR: the device list at ten or more devices, a device detail pane, a remote-device edit, the household switch, and a policy-capped row. UI changes need screenshots (`CLAUDE.md`, "Pull requests").
- [ ] **Step 3: Manual authorization pass**
With `curl` against a dev server, confirm each refusal returns the intended status and leaks nothing:
- non-primary naming a sibling profile → 403
- primary with an unverified PIN → 403
- a profile id from another account → 404
- a device id belonging to another profile → 404
- [ ] **Step 4: Cross-repo follow-up**
The two identity widenings are additive server capabilities that Apple and Android may adopt later; nothing in those clients breaks without a change. Note in the PR whether follow-up issues are wanted, per `CLAUDE.md` "Multi-repo".
---
## Out of scope, and why
- **Renaming a device.** `device_name` is client-reported and re-registration overwrites it, so a user-set name needs a separate column and a precedence rule. Worth doing; not part of this plan.
- **"Copy my settings from another device".** Appealing, but it writes up to 30 keys in one request, which is the argument for the batch mutations endpoint the design spec names (`POST /api/v1/settings/mutations`) and which does not exist. Build that first.
- **Transferring the primary designation.** `is_primary` is assigned implicitly to the first profile created (`internal/userstore/pgstore/profiles.go:60-73`) and cannot be moved. The household view makes that visible, so it likely needs to exist — as its own issue.
- **Watch history in the household view.** A different privacy question that a settings screen should not quietly answer.
- **Admin device screen rework.** Once users self-serve, `/admin/devices` can go on being a fleet console. Grouping its rows by what they affect and hiding raw keys behind a disclosure is worth doing separately.
## Adjacent gaps found while planning
Same shape as this work, but not blockers — each deserves its own issue:
- `PUT`/`DELETE /profiles/{id}/avatar` (`internal/api/handlers/profile_avatars.go:202`, `:290`) apply no household guard and no self-check: any profile can change or delete any sibling's avatar, including the primary's.
- `GET /profiles` (`internal/api/handlers/profiles.go:263`) returns every sibling's resolved preferences, `has_pin`, `is_child`, content rating and library restrictions to any profile on the account.
- `POST /profiles/{id}/verify-pin` (`internal/api/handlers/profiles.go:701`) is open to any authenticated user of the account for any profile id, with no rate limiting in the handler.
@@ -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` (`internal/playback/resolver.go`) 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` /
`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
duplicated codec logic).
## Transcode-to-file pipeline (v1)
@@ -1,301 +0,0 @@
# 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,13 +1,5 @@
# 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
@@ -42,29 +34,14 @@ The normalization rules are:
Recognized resolutions are the same tiers already supported by the transcode
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
## Where it lives now
## Architecture and Data Flow
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:
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.
- `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:
The normalized value replaces `req.TargetResolution`. Existing downstream
paths then automatically use one consistent value for:
- transcode-node planning and requests;
- integrated/local `playback.TranscodeOpts`;
+2 -2
View File
@@ -33,7 +33,6 @@ require (
github.com/open-policy-agent/opa v1.18.2
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/tetratelabs/wazero v1.12.0
github.com/wneessen/go-mail v0.7.3
github.com/zishang520/socket.io/v2 v2.5.0
@@ -81,6 +80,7 @@ require (
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.11.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -109,7 +109,7 @@ require (
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.13.0
github.com/Silo-Server/silo-plugin-sdk v0.12.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.13.0 h1:BXf4cuNbOIIsrVJrRdlQnbc1m04X6lcPSZI+AFLMeK0=
github.com/Silo-Server/silo-plugin-sdk v0.13.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
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/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=
-34
View File
@@ -1,34 +0,0 @@
package access
import (
"context"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// PreferredMetadataLanguage resolves catalog.metadata_language canonically for
// one profile: the stored profile-scope value, else the contract default. The
// legacy user_profiles.preferred_metadata_language column is deliberately not
// consulted — it migrated to the canonical store, and reading both would let
// them disagree.
//
// Resolution is unconstrained on purpose. The manifest gives this key no
// constrained_by because the policy input that could constrain it
// (profile_preferred_metadata_language) is populated from this very
// preference; a constraint here would be circular. See the key's notes in
// contracts/settings/v1/manifest.json.
//
// A resolution failure degrades to "" — the contract default, meaning "inherit
// the library's metadata language" — rather than failing scope resolution: the
// language is a presentation preference, not an access boundary. The failure
// itself is logged, though: before the cutover this value rode on the profile
// row whose load failure was a hard error, and a store outage that silently
// degrades every profile's metadata language would otherwise be
// indistinguishable from "nobody set a preference".
func PreferredMetadataLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
resolved, _ := resolveCanonicalViewerPreferences(ctx, store, profileID)
return resolved.preferences.PreferredMetadataLanguage
}
-152
View File
@@ -1,152 +0,0 @@
package access
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingStoreStub only answers the one method resolution reaches; the
// embedded nil interface panics on anything else, which is the point — this
// path must not touch the rest of the store.
type settingStoreStub struct {
userstore.UserStore
rows []userstore.SettingValue
err error
}
func (s settingStoreStub) ListSettingValuesForResolution(
context.Context, userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
return s.rows, s.err
}
type capturingLogHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *capturingLogHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *capturingLogHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
h.records = append(h.records, r)
h.mu.Unlock()
return nil
}
func (h *capturingLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *capturingLogHandler) WithGroup(string) slog.Handler { return h }
func (h *capturingLogHandler) snapshot() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
return append([]slog.Record(nil), h.records...)
}
func captureLogs(t *testing.T) *capturingLogHandler {
t.Helper()
handler := &capturingLogHandler{}
prev := slog.Default()
slog.SetDefault(slog.New(handler))
t.Cleanup(func() { slog.SetDefault(prev) })
return handler
}
// TestPreferredMetadataLanguageLogsStoreFailures pins the operator signal: the
// value deliberately degrades to "" on a store failure, but before the cutover
// it rode on the already-loaded profile row where a load failure was a hard
// error. A silent degrade would make transient pool exhaustion — or a
// persistently broken query path — indistinguishable from "no preference".
func TestPreferredMetadataLanguageLogsStoreFailures(t *testing.T) {
handler := captureLogs(t)
store := settingStoreStub{err: errors.New("connection pool exhausted")}
if got := PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
t.Fatalf("degraded value = %q, want \"\"", got)
}
records := handler.snapshot()
if len(records) == 0 {
t.Fatal("a store failure resolved to the default with no log output")
}
record := records[0]
if record.Level < slog.LevelWarn {
t.Errorf("logged at %v, want at least WARN", record.Level)
}
var loggedError, loggedProfile bool
record.Attrs(func(a slog.Attr) bool {
switch a.Key {
case "error":
loggedError = strings.Contains(a.Value.String(), "connection pool exhausted")
case "profile_id":
loggedProfile = a.Value.String() == "profile-1"
}
return true
})
if !loggedError {
t.Errorf("log %q does not carry the store error", record.Message)
}
if !loggedProfile {
t.Errorf("log %q does not name the profile", record.Message)
}
}
// TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored: the healthy
// no-preference answer must not spam the log.
func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
handler := captureLogs(t)
if got := PreferredMetadataLanguage(context.Background(), settingStoreStub{}, "profile-1"); got != "" {
t.Fatalf("no-preference value = %q, want \"\"", got)
}
if records := handler.snapshot(); len(records) != 0 {
t.Errorf("healthy resolution logged %d records, want none", len(records))
}
}
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
store := settingStoreStub{rows: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`[]`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`"en"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
},
}}
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
if preferences.PreferredMetadataLanguage != "en" {
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
}
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
if len(preferences.MetadataLanguageOverrides) != len(want) {
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
}
for source, target := range want {
if preferences.MetadataLanguageOverrides[source] != target {
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
}
}
}
+14 -31
View File
@@ -10,11 +10,8 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingKeyDisabledLibraryIDs is the legacy account-wide user-settings key
// that stored a JSON array of library IDs the user had chosen to hide. It is
// read only as a fallback now: the setting moved to the profile-scoped
// canonical key ui.disabled_library_ids, and the legacy write endpoint no
// longer accepts this key.
// settingKeyDisabledLibraryIDs is the user-settings key that stores a JSON
// array of library IDs the user has chosen to hide.
const settingKeyDisabledLibraryIDs = "disabled_library_ids"
// UserRepository loads account-level access settings.
@@ -75,7 +72,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
return Scope{}, fmt.Errorf("opening user store for %d: %w", input.UserID, err)
}
preferences := ResolveViewerPreferences(ctx, store, input.ProfileID)
if input.ProfileID != "" {
profile, err := store.GetProfile(ctx, input.ProfileID)
if err != nil {
@@ -87,8 +83,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = preferences.PreferredMetadataLanguage
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
if err != nil {
@@ -97,8 +92,8 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.ProfileVerified = verified
}
// Apply the profile's disabled library IDs setting.
disabled := preferences.DisabledLibraryIDs
// Apply user-level disabled library IDs setting.
disabled := DisabledLibraryIDs(ctx, store)
if len(disabled) > 0 {
if scope.AllowedLibraryIDs != nil {
// Restricted user: subtract disabled IDs from the allowed set.
@@ -144,29 +139,17 @@ func VerifyProfileForRequest(
return profileVerified, nil
}
// DisabledLibraryIDs resolves the libraries the acting profile has hidden from
// its own browsing: the canonical profile-scoped ui.disabled_library_ids row,
// else the legacy account-wide disabled_library_ids setting.
//
// The canonical row is what the web writes since the settings cutover — the
// legacy endpoint rejects the unregistered key, so an account-key read alone
// would silently ignore every edit made after the cutover. The legacy fallback
// stays because the one-time backfill only ran on stores that existed when it
// shipped: a store restored from a pre-backfill snapshot still carries its
// hidden libraries only in the account key, and dropping the fallback would
// unhide them. A stored canonical row always wins, so the fallback can never
// override a post-cutover edit.
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore, profileID string) []int {
return ResolveViewerPreferences(ctx, store, profileID).DisabledLibraryIDs
}
// parseLibraryIDList decodes a JSON library-id array, dropping anything that
// is not a positive id. Malformed JSON reads as an empty list.
func parseLibraryIDList(raw json.RawMessage) []int {
var ids []int
if err := json.Unmarshal(raw, &ids); err != nil {
// DisabledLibraryIDs reads and parses the disabled_library_ids user setting.
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
if err != nil || raw == "" {
return nil
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil
}
// Filter out invalid values.
n := 0
for _, id := range ids {
if id > 0 {
+1 -210
View File
@@ -2,14 +2,11 @@ package access
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -42,10 +39,6 @@ type stubStore struct {
profile *userstore.Profile
err error
settings map[string]string
// settingValues are the canonical setting rows the resolver may read
// through ListSettingValuesForResolution. Scope matching is the
// resolver's job, so the stub returns them unfiltered.
settingValues []userstore.SettingValue
}
func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") }
@@ -223,19 +216,7 @@ func (s stubStore) GetSetting(_ context.Context, key string) (string, error) {
}
return "", nil
}
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
func (s stubStore) GetOnboardingState(context.Context, string, string) (*userstore.OnboardingState, error) {
panic("unused")
}
func (s stubStore) UpsertOnboardingState(context.Context, userstore.OnboardingState) error {
panic("unused")
}
func (s stubStore) GetJellycompatDisplayPrefs(context.Context, string, string) (string, error) {
panic("unused")
}
func (s stubStore) SetJellycompatDisplayPrefs(context.Context, string, string, string) error {
panic("unused")
}
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
func (s stubStore) DeleteSetting(context.Context, string) error { panic("unused") }
func (s stubStore) ListSettings(context.Context) ([]userstore.SettingEntry, error) { panic("unused") }
func (s stubStore) GetDeviceSetting(context.Context, string, string, string) (*userstore.DeviceSettingEntry, error) {
@@ -290,42 +271,6 @@ func (s stubStore) UpsertLibraryPlaybackPreference(context.Context, userstore.Li
func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int) error {
panic("unused")
}
func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return s.settingValues, nil
}
func (s stubStore) ListAllSettingValues(context.Context) ([]userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValue(context.Context, userstore.SettingIdentity) (bool, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForProfile(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForDevice(context.Context, string, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForLibrary(context.Context, int) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForSeries(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) GetSettingMutation(context.Context, string) (*userstore.SettingMutationRecord, error) {
panic("unused")
}
func (s stubStore) PutSettingMutation(context.Context, userstore.SettingMutationRecord) (userstore.SettingMutationRecord, bool, error) {
panic("unused")
}
func (s stubStore) DeleteExpiredSettingMutations(context.Context, time.Time) (int64, error) {
panic("unused")
}
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
@@ -461,93 +406,6 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
}
}
func TestResolver_DisabledLibraries_CanonicalRowWins(t *testing.T) {
// The canonical profile-scoped ui.disabled_library_ids row wins; the
// legacy account key carries a decoy value that must not be read once a
// canonical row exists.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[9]"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`[3,5]`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 2 || scope.DisabledLibraryIDs[0] != 3 || scope.DisabledLibraryIDs[1] != 5 {
t.Fatalf("DisabledLibraryIDs = %v, want canonical [3 5]", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_CanonicalNullClearsLegacy(t *testing.T) {
// A stored null spells "no hidden libraries" and still wins over the
// legacy key: the row exists, so the profile has decided.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[9]"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`null`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 0 {
t.Fatalf("DisabledLibraryIDs = %v, want empty", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_ProfileIsolation(t *testing.T) {
// Profile A's canonical hidden-library list must not leak into profile B:
// with no canonical row of its own and no legacy key, B hides nothing.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-b"},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-a",
},
Value: json.RawMessage(`[3,5]`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-b"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if len(scope.DisabledLibraryIDs) != 0 {
t.Fatalf("DisabledLibraryIDs = %v, want empty for the other profile", scope.DisabledLibraryIDs)
}
}
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
@@ -569,73 +427,6 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
}
}
func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
// The canonical catalog.metadata_language row wins; the legacy profile
// column carries a decoy value that must no longer be read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
settingValues: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`"de"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`{"no":"x-silo-original"}`),
},
},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "de" {
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
}
if got := scope.MetadataLanguageOverrides["no"]; got != OriginalMetadataLanguage {
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, OriginalMetadataLanguage)
}
}
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
// A profile with only the legacy column value falls to the contract
// default ("" — inherit), proving the column is no longer read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "" {
t.Fatalf("PreferredMetadataLanguage = %q, want contract default \"\"", scope.PreferredMetadataLanguage)
}
}
func TestResolver_AppliesGroupPolicy(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{
-3
View File
@@ -12,9 +12,6 @@ type Scope struct {
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
// MetadataLanguageOverrides maps a media item's canonical original-language
// code to the profile's target metadata language for that source.
MetadataLanguageOverrides map[string]string
PolicyRevision int64
ProfileVerified bool
}
-145
View File
@@ -1,145 +0,0 @@
package access
import (
"context"
"encoding/json"
"log/slog"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ViewerPreferences are the canonical preferences needed while constructing
// an access scope. They are resolved together because this path runs on nearly
// every authenticated request and one candidate read can answer both keys.
type ViewerPreferences struct {
DisabledLibraryIDs []int
PreferredMetadataLanguage string
MetadataLanguageOverrides map[string]string
}
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
// one canonical store read. The legacy disabled_library_ids account setting is
// consulted only when no canonical row decided that value.
func ResolveViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) ViewerPreferences {
profileID = strings.TrimSpace(profileID)
if store == nil {
return ViewerPreferences{}
}
if profileID == "" {
return ViewerPreferences{DisabledLibraryIDs: legacyDisabledLibraryIDs(ctx, store)}
}
resolved, ok := resolveCanonicalViewerPreferences(ctx, store, profileID)
if !ok || !resolved.disabledLibraryIDsSet {
resolved.preferences.DisabledLibraryIDs = legacyDisabledLibraryIDs(ctx, store)
}
return resolved.preferences
}
type canonicalViewerPreferences struct {
preferences ViewerPreferences
disabledLibraryIDsSet bool
}
func resolveCanonicalViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) (canonicalViewerPreferences, bool) {
contract, err := settingscontract.Load()
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: loading settings contract failed",
"component", "access", "profile_id", profileID, "error", err)
return canonicalViewerPreferences{}, false
}
values, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{
settingskeys.UiDisabledLibraryIds,
settingskeys.CatalogMetadataLanguage,
settingskeys.CatalogMetadataLanguageOverrides,
}, nil)
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: reading setting values failed",
"component", "access", "profile_id", profileID, "error", err)
return canonicalViewerPreferences{}, false
}
var out canonicalViewerPreferences
for _, value := range values {
switch value.Key {
case settingskeys.UiDisabledLibraryIds:
out.disabledLibraryIDsSet = value.Source != settingscontract.ScopeDefault
if out.disabledLibraryIDsSet {
out.preferences.DisabledLibraryIDs = parseLibraryIDList(value.Value)
}
case settingskeys.CatalogMetadataLanguage:
var language string
if json.Unmarshal(value.Value, &language) == nil {
out.preferences.PreferredMetadataLanguage = strings.TrimSpace(language)
}
case settingskeys.CatalogMetadataLanguageOverrides:
out.preferences.MetadataLanguageOverrides = parseMetadataLanguageOverrides(value.Value)
}
}
return out, true
}
// OriginalMetadataLanguage is a private-use BCP 47 tag stored in
// catalog.metadata_language (or as an override target) to mean "resolve this
// media item's original language." Keeping the sentinel a valid language tag
// preserves the existing setting's wire type.
const OriginalMetadataLanguage = "x-silo-original"
// parseMetadataLanguageOverrides normalizes source aliases and target tag
// casing before the map reaches catalog serving. The contract only accepts
// canonical source keys from normal clients, but normalization also makes old
// or manually-authored rows deterministic.
func parseMetadataLanguageOverrides(raw json.RawMessage) map[string]string {
var stored map[string]string
if json.Unmarshal(raw, &stored) != nil || len(stored) == 0 {
return nil
}
keys := make([]string, 0, len(stored))
for source := range stored {
keys = append(keys, source)
}
sort.Strings(keys)
out := make(map[string]string, len(stored))
for _, source := range keys {
canonicalSource := lang.Canonical(source)
if canonicalSource == "" {
continue
}
// When aliases collapse (for example en and eng), the canonical key
// wins because it sorts first and is never overwritten.
if _, exists := out[canonicalSource]; exists {
continue
}
target, ok := settingscontract.NormalizeLanguageTag(stored[source])
if !ok {
continue
}
out[canonicalSource] = target
}
if len(out) == 0 {
return nil
}
return out
}
func legacyDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
if err != nil || raw == "" {
return nil
}
return parseLibraryIDList(json.RawMessage(raw))
}
+4 -26
View File
@@ -40,28 +40,13 @@ type deleteLibraryExecutor interface {
Execute(ctx context.Context, req DeleteLibraryRequest, progress func(current, total int, message string)) (*DeleteLibraryResult, error)
}
// LibrarySettingsCleaner removes per-user canonical setting values scoped to a
// deleted library. Satisfied by *userstore.SettingValuesCleaner.
type LibrarySettingsCleaner interface {
DeleteForLibrary(ctx context.Context, libraryID int) int64
}
type LibraryDeleteExecutor struct {
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
settingsCleaner LibrarySettingsCleaner
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
}
func NewLibraryDeleteExecutor(
folderRepo *catalog.FolderRepository,
sectionRepo *sections.Repository,
settingsCleaner LibrarySettingsCleaner,
) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{
folderRepo: folderRepo,
sectionRepo: sectionRepo,
settingsCleaner: settingsCleaner,
}
func NewLibraryDeleteExecutor(folderRepo *catalog.FolderRepository, sectionRepo *sections.Repository) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{folderRepo: folderRepo, sectionRepo: sectionRepo}
}
func (e *LibraryDeleteExecutor) Execute(
@@ -97,13 +82,6 @@ func (e *LibraryDeleteExecutor) Execute(
return nil, fmt.Errorf("deleting generated home sections: %w", err)
}
}
if e.settingsCleaner != nil {
// The canonical settings schema declares no FK on library_id, so the
// per-user profile_library values must go with the library or they
// orphan. Best-effort inside the cleaner: the library itself is
// already deleted at this point.
e.settingsCleaner.DeleteForLibrary(ctx, req.LibraryID)
}
if progress != nil {
progress(5, 5, "Library deletion completed")
}
+4 -35
View File
@@ -99,51 +99,20 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
PluginID: inst.PluginID,
CapabilityID: c.ID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
Description: scanSourceMetadataString(c, "description"),
Descriptor: scanSourceDescriptor(inst.PluginID, c),
})
}
}
return out, nil
}
// scanSourceMetadataString reads a trimmed string out of a capability's
// manifest metadata, returning "" when absent or of the wrong type.
func scanSourceMetadataString(c *plugins.Capability, key string) string {
if c == nil || c.Metadata == nil {
return ""
}
value, ok := c.Metadata[key].(string)
if !ok {
return ""
}
return strings.TrimSpace(value)
}
// scanSourceDescriptor resolves the setup contract for a discovered capability.
// It reads whatever the manifest declares (falling back to host defaults that
// reproduce pre-descriptor behavior), then lets a host-side compatibility
// descriptor fill gaps for first-party plugins that have not yet published one
// of their own.
func scanSourceDescriptor(pluginID string, c *plugins.Capability) autoscan.ScanSourceDescriptor {
var metadata map[string]any
if c != nil {
metadata = c.Metadata
}
declared := autoscan.DescriptorFromMetadata(metadata)
capabilityID := ""
if c != nil {
capabilityID = c.ID
}
return autoscan.ApplyCompatibilityDescriptor(pluginID, capabilityID, declared)
}
// scanSourceDisplayName derives a human-friendly label for a scan_source
// capability: the capability manifest's display_name when present, else the
// plugin id (with the capability id appended when it adds information).
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
if name := scanSourceMetadataString(c, "display_name"); name != "" {
return name
if c != nil && c.Metadata != nil {
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
}
switch {
case pluginID != "" && c != nil && c.ID != "":
+366 -55
View File
@@ -37,8 +37,6 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/policy"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -1357,6 +1355,10 @@ type adminSettingResponse struct {
RestartRequired bool `json:"restart_required,omitempty"`
}
type adminSettingsListResponse struct {
Settings []adminSettingResponse `json:"settings"`
}
type adminDeviceSettingResponse struct {
UserID int `json:"user_id"`
ProfileID string `json:"profile_id"`
@@ -1411,6 +1413,345 @@ type adminDeviceDetailResponse struct {
Settings []adminDeviceSettingResponse `json:"settings"`
}
// HandleListUserSettings handles GET /admin/users/{id}/settings.
func (h *AdminHandler) HandleListUserSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
resp := adminSettingsListResponse{
Settings: make([]adminSettingResponse, 0, len(entries)),
}
for _, entry := range entries {
if !keyUsesUserScope(entry.Key) {
continue
}
resp.Settings = append(resp.Settings, adminSettingResponse{
Key: entry.Key,
Value: entry.Value,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetUserSetting handles GET /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleGetUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
value, err := store.GetSetting(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
if value == "" {
entries, err := store.ListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
return
}
found := false
for _, entry := range entries {
if entry.Key == key {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
return
}
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
}
// HandleUpdateUserSetting handles PUT /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleUpdateUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserSetting handles DELETE /admin/users/{id}/settings/{key}.
func (h *AdminHandler) HandleDeleteUserSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if !keyUsesUserScope(key) {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteSetting(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListUserDeviceSettings handles GET /admin/users/{id}/device-settings.
func (h *AdminHandler) HandleListUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListAllDeviceSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleListUserDeviceSettingsByKey handles GET /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleListUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
entries, err := store.ListDeviceSettings(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
return
}
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
}
// HandleUpdateUserDeviceSetting handles PUT /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
var req updateSettingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
existing, err := store.GetDeviceSetting(r.Context(), profileID, deviceID, key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device setting")
return
}
entry := userstore.DeviceSettingEntry{
ProfileID: profileID,
DeviceID: deviceID,
Key: key,
Value: req.Value,
}
if existing != nil {
entry.DeviceName = existing.DeviceName
entry.DevicePlatform = existing.DevicePlatform
} else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
} else if registered != nil {
entry.DeviceName = registered.DeviceName
entry.DevicePlatform = registered.DevicePlatform
}
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
}
// HandleDeleteUserDeviceSetting handles DELETE /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
func (h *AdminHandler) HandleDeleteUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
key := strings.TrimSpace(chi.URLParam(r, "key"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteDeviceSetting(r.Context(), profileID, deviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteAllUserDeviceSettings handles DELETE /admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings.
func (h *AdminHandler) HandleDeleteAllUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if !adminProfileExists(w, r, store, profileID) {
return
}
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDeleteUserDeviceSettingsByKey handles DELETE /admin/users/{id}/device-settings/{key}.
func (h *AdminHandler) HandleDeleteUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
return
}
store, ok := h.adminUserStore(w, r, userID)
if !ok {
return
}
if err := store.DeleteDeviceSettingsByKey(r.Context(), key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /admin/devices.
func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
if h.userRepo == nil || h.storeProv == nil {
@@ -1438,10 +1779,6 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
if err != nil {
return fmt.Errorf("list device settings: %w", err)
}
canonicalValues, err := store.ListAllSettingValues(gctx)
if err != nil {
return fmt.Errorf("list canonical setting values: %w", err)
}
devices, err := listRegisteredDevices(gctx, store)
if err != nil {
return fmt.Errorf("list devices: %w", err)
@@ -1459,7 +1796,6 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
user.Username,
user.Email,
entries,
canonicalValues,
devices,
profileNames,
)
@@ -1526,11 +1862,6 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
}
canonicalValues, err := store.ListAllSettingValues(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
return
}
registeredDevices, err := listRegisteredDevices(r.Context(), store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
@@ -1554,18 +1885,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
deviceRegistrations = append(deviceRegistrations, entry)
}
}
deviceCanonicalValues := make([]userstore.SettingValue, 0)
for _, value := range canonicalValues {
if value.Scope == settingscontract.ScopeProfileDevice && value.DeviceID == deviceID {
deviceCanonicalValues = append(deviceCanonicalValues, value)
}
}
summaries := buildAdminDeviceSummaries(
user.ID,
user.Username,
user.Email,
deviceEntries,
deviceCanonicalValues,
deviceRegistrations,
profileNames,
)
@@ -1598,6 +1922,25 @@ func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]us
return registry.ListDevices(ctx)
}
func registeredDeviceForProfile(
ctx context.Context,
store userstore.UserStore,
profileID string,
deviceID string,
) (*userstore.DeviceEntry, error) {
devices, err := listRegisteredDevices(ctx, store)
if err != nil {
return nil, err
}
for _, device := range devices {
if device.ProfileID == profileID && device.DeviceID == deviceID {
matched := device
return &matched, nil
}
}
return nil, nil
}
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
resp := adminDeviceSettingsListResponse{
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
@@ -1623,7 +1966,6 @@ func buildAdminDeviceSummaries(
username string,
email string,
entries []userstore.DeviceSettingEntry,
canonicalValues []userstore.SettingValue,
registeredDevices []userstore.DeviceEntry,
profileNames map[string]string,
) []adminDeviceSummaryResponse {
@@ -1725,36 +2067,12 @@ func buildAdminDeviceSummaries(
if current == nil {
continue
}
key := canonicalAdminDeviceSettingKey(entry.Key)
if profileID != "" && key != "" {
current.keys[profileID+":"+key] = struct{}{}
if profileID != "" && entry.Key != "" {
current.keys[profileID+":"+entry.Key] = struct{}{}
}
profile := ensureProfile(current, profileID, entry.UpdatedAt)
if profile != nil && key != "" {
profile.keys[key] = struct{}{}
}
}
// Canonical profile_device rows are the authoritative overrides after the
// settings cutover. Merge them by (profile,key) with the still-mounted
// legacy rows so a mirrored value counts once while a canonical-only write
// remains visible to fleet management.
for _, value := range canonicalValues {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
deviceID := strings.TrimSpace(value.DeviceID)
profileID := strings.TrimSpace(value.ProfileID)
current := ensureDevice(deviceID, "", "", value.UpdatedAt)
if current == nil {
continue
}
if profileID != "" && value.Key != "" {
current.keys[profileID+":"+value.Key] = struct{}{}
}
profile := ensureProfile(current, profileID, value.UpdatedAt)
if profile != nil && value.Key != "" {
profile.keys[value.Key] = struct{}{}
if profile != nil && entry.Key != "" {
profile.keys[entry.Key] = struct{}{}
}
}
@@ -1787,13 +2105,6 @@ func buildAdminDeviceSummaries(
return devices
}
// canonicalAdminDeviceSettingKey uses the migration's rename table so fleet
// counts describe logical overrides and every legacy/canonical pair counts
// once, including pre-cutover appearance rows left in the legacy table.
func canonicalAdminDeviceSettingKey(key string) string {
return settingsmigrate.CanonicalKey(strings.TrimSpace(key))
}
func listProfileNamesByID(ctx context.Context, store userstore.UserStore) (map[string]string, error) {
profiles, err := store.ListProfiles(ctx)
if err != nil {
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
)
const (
@@ -20,7 +22,17 @@ const (
)
type AdminPlaybackControlHandler struct {
playback *PlaybackHandler
playback *PlaybackHandler
revocation *streamrevoke.Store
}
// SetRevocationStore wires the kill switch so an admin terminate revokes the
// stream credential (stops it at the edge and refuses reconnects), not just
// dispatches a cooperative realtime command. Optional; nil keeps prior behavior.
func (h *AdminPlaybackControlHandler) SetRevocationStore(store *streamrevoke.Store) {
if h != nil {
h.revocation = store
}
}
type playbackControlRequest struct {
@@ -145,9 +157,38 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
return
}
var req playbackControlRequest
if err := decodeOptionalJSONBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
// A terminate must stick even if the client ignores the realtime command:
// revoke the stream credential so the edge refuses further segments and any
// reconnect within one propagation/poll interval. Stop stays cooperative.
// The revocation is written BEFORE the local session lookup: the kill needs
// only the id, and the stream may exist only as an edge Redis record — e.g.
// after a central restart, or when a client that withholds progress let the
// in-memory session be reaped — which is precisely the stream an operator
// most needs to kill. Revoking an unknown id is harmless (idempotent, TTL'd).
if name == playback.CommandTerminate && h.revocation != nil {
if err := h.revocation.RevokeSession(r.Context(), sessionID, "admin_terminate"); err != nil {
slog.Warn("admin terminate: revoke stream failed", "session_id", sessionID, "error", err)
}
}
session, err := h.playback.sessionMgr.GetSession(sessionID)
if err != nil {
if errors.Is(err, playback.ErrSessionNotFound) {
// The revocation above still cut the stream at every serve surface;
// only the cooperative realtime command has no local session to go to.
if name == playback.CommandTerminate && h.revocation != nil {
writeJSON(w, http.StatusAccepted, playbackControlResponse{
CommandID: uuid.NewString(),
Status: "revoked",
})
return
}
writeError(w, http.StatusNotFound, "not_found", "Playback session not found")
return
}
@@ -155,12 +196,6 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
return
}
var req playbackControlRequest
if err := decodeOptionalJSONBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if requiresLivePlaybackControl(name) && (session == nil || !session.HasRealtimeConnection) {
writeError(w, http.StatusConflict, "realtime_unavailable", "Realtime connection unavailable for playback session")
return
@@ -0,0 +1,224 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
)
const (
maxStreamRevocationBodyBytes = 16 << 10
maxStreamRevocationIDLength = 256
maxStreamRevocationReason = 512
maxStreamRevocationTTL = 30 * 24 * 60 * 60
)
// AdminStreamRevocationHandler exposes the operator-visible stream kill list.
// Unrevoking an over-cap victim is legal, but the async enforcer may revoke it
// again on its next pass while the owning user remains over cap.
type AdminStreamRevocationHandler struct {
store *streamrevoke.Store
}
func NewAdminStreamRevocationHandler(store *streamrevoke.Store) *AdminStreamRevocationHandler {
return &AdminStreamRevocationHandler{store: store}
}
type streamRevocationRequest struct {
Kind string `json:"kind"`
ID string `json:"id"`
Reason string `json:"reason"`
TTLSeconds *int64 `json:"ttl_seconds"`
}
type streamRevocationResponse struct {
streamrevoke.Revocation
Warnings []string `json:"warnings,omitempty"`
}
// streamRevocationKindsByWire is the single source of truth for the closed
// {kind} vocabulary the revocation routes accept. Both the wire parser
// (revocationKeyFromWire) and the capability advertisement (streamRevocationKinds)
// read it, so adding a kind here automatically advertises it and the two cannot
// drift apart. An earlier version duplicated the list in a switch statement,
// which meant a newly-accepted kind could go unadvertised — clients would then
// feature-detect an incomplete vocabulary.
var streamRevocationKindsByWire = map[string]streamrevoke.Kind{
"session": streamrevoke.KindSession,
string(streamrevoke.KindSession): streamrevoke.KindSession,
string(streamrevoke.KindUser): streamrevoke.KindUser,
}
// streamRevocationKinds returns the accepted {kind} values in sorted order.
// Sorted because map iteration order is randomised and the advertisement is a
// wire response that must be stable across calls.
func streamRevocationKinds() []string {
kinds := make([]string, 0, len(streamRevocationKindsByWire))
for kind := range streamRevocationKindsByWire {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
return kinds
}
// streamRevocationCapabilitiesResponse advertises the operator kill-list
// endpoints so clients and operator tooling can feature-detect them rather than
// probing for a 404. These routes are mounted only when a revocation store is
// wired, which is why this endpoint is mounted in the same block.
type streamRevocationCapabilitiesResponse struct {
// StreamRevocations reports that the admin kill-list endpoints exist.
StreamRevocations bool `json:"stream_revocations"`
// StreamRevocationKinds is the closed {kind} vocabulary accepted by
// DELETE /admin/streams/revocations/{kind}/{id}.
StreamRevocationKinds []string `json:"stream_revocation_kinds"`
// StreamRevocationUnrevoke reports that revocations can be lifted, not just
// listed and created.
StreamRevocationUnrevoke bool `json:"stream_revocation_unrevoke"`
}
// HandleGetCapabilities exposes additive feature support for the stream
// kill list (GET /admin/streams/revocations/capabilities).
func (h *AdminStreamRevocationHandler) HandleGetCapabilities(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, streamRevocationCapabilitiesResponse{
StreamRevocations: true,
StreamRevocationKinds: streamRevocationKinds(),
StreamRevocationUnrevoke: true,
})
}
func (h *AdminStreamRevocationHandler) HandleList(w http.ResponseWriter, _ *http.Request) {
revocations := h.store.List()
sort.Slice(revocations, func(i, j int) bool {
return revocations[i].RevokedAt.After(revocations[j].RevokedAt)
})
writeJSON(w, http.StatusOK, map[string]any{"revocations": revocations})
}
func (h *AdminStreamRevocationHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req streamRevocationRequest
r.Body = http.MaxBytesReader(w, r.Body, maxStreamRevocationBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := ensureJSONEOF(decoder); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
key, err := revocationKeyFromWire(req.Kind, req.ID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
reason := strings.TrimSpace(req.Reason)
if len(reason) > maxStreamRevocationReason {
writeError(w, http.StatusBadRequest, "bad_request", "Reason is too long")
return
}
var until time.Time
if req.TTLSeconds != nil {
if *req.TTLSeconds <= 0 || *req.TTLSeconds > maxStreamRevocationTTL {
writeError(w, http.StatusBadRequest, "bad_request", "ttl_seconds must be between 1 and 2592000")
return
}
until = time.Now().Add(time.Duration(*req.TTLSeconds) * time.Second)
} else {
// A zero expiry asks the store to use its default via the public helper.
var warnings []string
switch key.Kind {
case streamrevoke.KindSession:
warnings, err = h.store.RevokeSessionWithWarnings(r.Context(), key.ID, reason)
case streamrevoke.KindUser:
userID, _ := strconv.Atoi(key.ID)
warnings, err = h.store.RevokeUserWithWarnings(r.Context(), userID, reason)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create revocation")
return
}
writeJSON(w, http.StatusCreated, streamRevocationResponse{Revocation: findRevocation(h.store, key), Warnings: warnings})
return
}
warnings, err := h.store.RevokeWithWarnings(r.Context(), key, reason, until)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create revocation")
return
}
writeJSON(w, http.StatusCreated, streamRevocationResponse{Revocation: findRevocation(h.store, key), Warnings: warnings})
}
func (h *AdminStreamRevocationHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
key, err := revocationKeyFromWire(chi.URLParam(r, "kind"), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
warnings, err := h.store.UnrevokeWithWarnings(r.Context(), key)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove revocation")
return
}
if len(warnings) > 0 {
writeJSON(w, http.StatusOK, map[string]any{"warnings": warnings})
return
}
w.WriteHeader(http.StatusNoContent)
}
func revocationKeyFromWire(kind, id string) (streamrevoke.Key, error) {
internalKind, ok := streamRevocationKindsByWire[kind]
if !ok {
return streamrevoke.Key{}, fmt.Errorf("kind must be one of %s", strings.Join(streamRevocationKinds(), ", "))
}
if internalKind == streamrevoke.KindSession {
id = strings.TrimSpace(id)
if id == "" {
return streamrevoke.Key{}, errors.New("session id is required")
}
if len(id) > maxStreamRevocationIDLength {
return streamrevoke.Key{}, errors.New("session id is too long")
}
return streamrevoke.Key{Kind: internalKind, ID: id}, nil
}
userID, err := strconv.Atoi(id)
if err != nil || userID <= 0 || strconv.Itoa(userID) != id {
return streamrevoke.Key{}, errors.New("user id must be a canonical positive integer")
}
return streamrevoke.Key{Kind: internalKind, ID: id}, nil
}
func ensureJSONEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values")
}
return err
}
return nil
}
func findRevocation(store *streamrevoke.Store, key streamrevoke.Key) streamrevoke.Revocation {
for _, revocation := range store.List() {
if revocation.Kind == key.Kind && revocation.ID == key.ID {
return revocation
}
}
return streamrevoke.Revocation{Kind: key.Kind, ID: key.ID}
}
@@ -0,0 +1,203 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
)
func streamRevocationTestRouter(store *streamrevoke.Store) http.Handler {
h := NewAdminStreamRevocationHandler(store)
r := chi.NewRouter()
r.Get("/revocations", h.HandleList)
r.Post("/revocations", h.HandleCreate)
r.Delete("/revocations/{kind}/{id}", h.HandleDelete)
return r
}
func TestAdminStreamRevocationRequiresAdminMiddleware(t *testing.T) {
const adminRole = "admin"
protected := apimw.RequireActingAdmin(nil)(streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{})))
for _, tc := range []struct {
name string
claims *auth.Claims
status int
}{
{name: "unauthenticated", status: http.StatusUnauthorized},
{name: "non admin", claims: &auth.Claims{UserID: 1, Role: string(scopeUser)}, status: http.StatusForbidden},
{name: adminRole, claims: &auth.Claims{UserID: 1, Role: adminRole}, status: http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/revocations", nil)
if tc.claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), tc.claims))
}
rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status=%d, want %d", rec.Code, tc.status)
}
})
}
}
func TestAdminStreamRevocationSessionMappingAndListDeleteRoundTrip(t *testing.T) {
store := streamrevoke.New(streamrevoke.Options{})
router := streamRevocationTestRouter(store)
req := httptest.NewRequest(http.MethodPost, "/revocations", bytes.NewBufferString(`{"kind":"session","id":" s1 ","reason":" test "}`))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
var created map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if created["kind"] != "sess" || created["id"] != "s1" {
t.Fatalf("created=%v, want internal sess kind and trimmed id", created)
}
listReq := httptest.NewRequest(http.MethodGet, "/revocations", nil)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
var listed struct {
Revocations []streamrevoke.Revocation `json:"revocations"`
}
if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
if len(listed.Revocations) != 1 {
t.Fatalf("list=%+v", listed.Revocations)
}
deletePath := "/revocations/" + string(listed.Revocations[0].Kind) + "/" + listed.Revocations[0].ID
deleteReq := httptest.NewRequest(http.MethodDelete, deletePath, nil)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusNoContent || len(store.List()) != 0 {
t.Fatalf("delete status=%d remaining=%v", deleteRec.Code, store.List())
}
}
func TestAdminStreamRevocationValidation(t *testing.T) {
router := streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{}))
tests := []struct {
name string
body string
}{
{name: "noncanonical user", body: `{"kind":"user","id":"01"}`},
{name: "zero user", body: `{"kind":"user","id":"0"}`},
{name: "unknown kind", body: `{"kind":"stream","id":"x"}`},
{name: "missing session id", body: `{"kind":"session","id":" "}`},
{name: "zero ttl", body: `{"kind":"sess","id":"x","ttl_seconds":0}`},
{name: "negative ttl", body: `{"kind":"sess","id":"x","ttl_seconds":-1}`},
{name: "ttl above max", body: `{"kind":"sess","id":"x","ttl_seconds":2592001}`},
{name: "ttl integer overflow", body: `{"kind":"sess","id":"x","ttl_seconds":9223372036854775808}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/revocations", bytes.NewBufferString(tt.body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestAdminStreamRevocationDeleteAbsentIsIdempotent(t *testing.T) {
router := streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{}))
req := httptest.NewRequest(http.MethodDelete, "/revocations/sess/missing", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestStreamRevocationCapabilitiesAdvertisesKillListSurface(t *testing.T) {
h := NewAdminStreamRevocationHandler(streamrevoke.New(streamrevoke.Options{}))
rr := httptest.NewRecorder()
h.HandleGetCapabilities(rr, httptest.NewRequest(http.MethodGet, "/revocations/capabilities", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
var got streamRevocationCapabilitiesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if !got.StreamRevocations || !got.StreamRevocationUnrevoke {
t.Fatalf("capabilities did not advertise the kill list: %+v", got)
}
if len(got.StreamRevocationKinds) == 0 {
t.Fatal("no revocation kinds advertised")
}
}
// The advertised {kind} vocabulary must be exactly what the wire parser accepts,
// in both directions. Sampling a handful of rejected strings is not enough: it
// cannot catch a kind the parser starts accepting without advertising it. Since
// both now read streamRevocationKindsByWire, the check is over the whole map.
func TestAdvertisedRevocationKindsMatchWireParser(t *testing.T) {
advertised := streamRevocationKinds()
if len(advertised) != len(streamRevocationKindsByWire) {
t.Fatalf("advertised %d kinds but the parser accepts %d; the two have drifted",
len(advertised), len(streamRevocationKindsByWire))
}
for _, kind := range advertised {
if _, ok := streamRevocationKindsByWire[kind]; !ok {
t.Errorf("advertised kind %q is not in the parser vocabulary", kind)
}
if _, err := revocationKeyFromWire(kind, "1"); err != nil {
t.Errorf("advertised kind %q rejected by revocationKeyFromWire: %v", kind, err)
}
}
for kind := range streamRevocationKindsByWire {
if !slices.Contains(advertised, kind) {
t.Errorf("parser accepts kind %q but it is not advertised", kind)
}
}
for _, kind := range []string{"", "users", "sessions", "profile", "USER", "device"} {
if _, err := revocationKeyFromWire(kind, "1"); err == nil {
t.Errorf("kind %q outside the vocabulary was accepted", kind)
}
}
}
func TestAdvertisedRevocationKindsAreSortedAndStable(t *testing.T) {
first := streamRevocationKinds()
if !slices.IsSorted(first) {
t.Errorf("advertised kinds are not sorted: %v", first)
}
// Map iteration order is randomised, so an unsorted implementation would
// return a different order across calls for the same wire contract.
for i := 0; i < 20; i++ {
if got := streamRevocationKinds(); !slices.Equal(got, first) {
t.Fatalf("advertised kinds unstable across calls: %v then %v", first, got)
}
}
}
func TestCapabilitiesKindsAreNotAliasedToPackageState(t *testing.T) {
h := NewAdminStreamRevocationHandler(streamrevoke.New(streamrevoke.Options{}))
rr := httptest.NewRecorder()
h.HandleGetCapabilities(rr, httptest.NewRequest(http.MethodGet, "/revocations/capabilities", nil))
var got streamRevocationCapabilitiesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
got.StreamRevocationKinds[0] = "mutated"
if slices.Contains(streamRevocationKinds(), "mutated") {
t.Fatal("handler returned a slice aliasing package state; a caller can corrupt the vocabulary")
}
}
+4 -27
View File
@@ -7,18 +7,12 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// AudioPrefHandler handles per-series audio preference endpoints. Concrete
// track identity remains in the specialized table; the language is mirrored
// to the canonical profile_series row consumed by playback.
// AudioPrefHandler handles per-series audio preference endpoints.
type AudioPrefHandler struct {
storeProvider userstore.UserStoreProvider
EventsHub *evt.Hub
}
// NewAudioPrefHandler creates a new AudioPrefHandler.
@@ -106,20 +100,9 @@ func (h *AudioPrefHandler) HandleSetAudioPref(w http.ResponseWriter, r *http.Req
AudioLanguage: req.AudioLanguage,
TrackSignature: req.TrackSignature,
}
language := req.AudioLanguage
sync, err := appendStringSync(nil, settingskeys.PlaybackAudioLanguage, &language)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if err := applyLegacyPreferenceSettingsSync(r.Context(), store, h.EventsHub, userID,
userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries, ProfileID: profileID, SeriesID: seriesID,
}, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.SetAudioPreference(r.Context(), pref)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store audio preference")
if err := store.SetAudioPreference(r.Context(), pref); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set audio preference")
return
}
@@ -143,13 +126,7 @@ func (h *AudioPrefHandler) HandleDeleteAudioPref(w http.ResponseWriter, r *http.
return
}
if err := applyLegacyPreferenceSettingsSync(r.Context(), store, h.EventsHub, userID,
userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries, ProfileID: profileID, SeriesID: seriesID,
}, []profileSettingSync{{key: settingskeys.PlaybackAudioLanguage}},
func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteAudioPreference(r.Context(), profileID, seriesID)
}); err != nil {
if err := store.DeleteAudioPreference(r.Context(), profileID, seriesID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete audio preference")
return
}
-81
View File
@@ -1,81 +0,0 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func routeAudioPref(
t *testing.T,
h *AudioPrefHandler,
method string,
seriesID string,
body []byte,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(method, "/audio-prefs/"+seriesID, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("series_id", seriesID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
if method == http.MethodPut {
h.HandleSetAudioPref(rec, req)
} else {
h.HandleDeleteAudioPref(rec, req)
}
return rec
}
func TestLegacyAudioPreferenceKeepsTrackIdentityAndSyncsCanonicalLanguage(t *testing.T) {
_, store := newValuesTestHandler(t)
handler := NewAudioPrefHandler(testUserStoreProvider{store: store})
rec := routeAudioPref(t, handler, http.MethodPut, "series-1", []byte(`{
"audio_track_index":2,
"audio_language":"ja",
"track_signature":{"language":"ja","codec":"aac","channels":2}
}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
legacy, err := store.GetAudioPreference(context.Background(), "profile-1", "series-1")
if err != nil || legacy == nil {
t.Fatalf("reading specialized preference: value=%+v err=%v", legacy, err)
}
if legacy.AudioTrackIndex != 2 || legacy.TrackSignature == nil {
t.Errorf("specialized track identity was lost: %+v", legacy)
}
canonicalID := userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage, Scope: settingscontract.ScopeProfileSeries,
ProfileID: "profile-1", SeriesID: "series-1",
}
canonical, err := store.GetSettingValue(context.Background(), canonicalID)
if err != nil || canonical == nil || string(canonical.Value) != `"ja"` {
t.Fatalf("canonical language = %+v err=%v, want ja", canonical, err)
}
// Empty is the legacy spelling of unset. The track identity remains
// specialized, while the canonical language inherits from the next scope.
rec = routeAudioPref(t, handler, http.MethodPut, "series-1",
[]byte(`{"audio_track_index":2,"audio_language":""}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("clearing PUT = %d: %s", rec.Code, rec.Body.String())
}
canonical, err = store.GetSettingValue(context.Background(), canonicalID)
if err != nil || canonical != nil {
t.Fatalf("empty language left canonical value=%+v err=%v", canonical, err)
}
rec = routeAudioPref(t, handler, http.MethodDelete, "series-1", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
}

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