Compare commits

...
Author SHA1 Message Date
Quick104 f39667b469 fix(watchsync): harden connection form contracts 2026-08-07 09:35:07 -04:00
Quick104 936f1990af fix(watchsync): tighten connection schema handling 2026-08-06 16:26:51 -04:00
Quick104 f1a909e7ad fix(watchsync): close connection validation gaps 2026-08-06 16:21:42 -04:00
Quick104 2dd4069009 fix(watchsync): harden plugin connection schemas 2026-08-06 16:05:52 -04:00
Quick104 3d12f467dd fix(watchsync): allow cold provider resolution 2026-08-06 15:26:04 -04:00
Quick104 5400717c3e fix(watchsync): preserve provider event semantics 2026-08-06 15:11:19 -04:00
Quick104 10d4624222 chore(deps): use plugin SDK v0.13.1 2026-08-06 13:45:23 -04:00
Quick104 6f445c1cda fix(watchsync): support declarative setup fallbacks 2026-08-06 13:12:39 -04:00
Quick104 d4edf0068e chore(deps): update plugin SDK snapshot 2026-08-06 12:53:41 -04:00
Quick104 9c9e10e373 fix(watchsync): harden connection config forms 2026-08-06 12:50:45 -04:00
Quick104 c39737e456 fix(watchsync): preserve plugin connection forms 2026-08-06 12:21:51 -04:00
Quick104 371d959cc8 fix(watchsync): address connection form review 2026-08-06 11:46:57 -04:00
Quick104 b4542bb53c feat(watchsync): add per-connection plugin config 2026-08-06 11:37:45 -04:00
fa1d7ba2b4 fix(playback): freeze v3 seek reanchor recipes (#548)
* fix(playback): freeze v3 seek reanchor recipes

* fix(playback): address v3 recipe review feedback

* test(playback): isolate v3 fallback route fixture

* fix(database): split v3 recipe constraint validation

* fix(playback): preserve frozen seek recipe identity

---------

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

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

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

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

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

---------

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

* fix(scanner): harden long duration fallbacks

* fix(scanner): address long duration review feedback

* fix(scanner): align long duration seek semantics

* fix(scanner): align legacy long media seeking

* fix(scanner): preserve long timeline origins

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

* fix(scanner): distinguish absolute-end timelines

* fix(scanner): preserve long resume semantics

* fix(scanner): reject ambiguous absolute ends

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style: gofmt touched files

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

---------

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

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

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

* feat(admin): add mobile settings overview

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

* fix(web): harden library state persistence

* fix(web): reconcile rejected library state writes

* fix(web): make library state persistence lossless

* fix(web): serialize library state across remounts

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

* fix(web): scope profile verification cleanup

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

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-03 12:41:51 -04:00
2dedf3ff26 feat(metadata): user-triggered trailer refresh with weekly per-item cooldown (#531)
* feat(metadata): user-triggered trailer refresh with weekly per-item cooldown

Adds POST /api/v1/items/{id}/trailers/refresh so any viewer with access to a
movie or series can ask the server to fetch its remote trailers, bounded by a
one-week per-item cooldown enforced server-side.

The cooldown lives in a new nullable media_items.trailers_refresh_requested_at
column rather than the refresh debt queue, whose last_attempt_at evaporates on
success (MarkTargetSuccess deletes the row when the reason mask clears). The
gate is a single UPDATE that writes NOW() only when the stored timestamp is
NULL or older than the window, so concurrent viewers cannot both win it; a
losing caller reads the stored timestamp back to compute next_allowed_at.

MetadataService.RequestTrailersRefresh resolves the per-library trailer_kinds
allow-list first: a non-nil empty map means every containing library disabled
remote videos, which answers "disabled" without consuming the cooldown slot
(a nil map is allow-all and must not short-circuit). On winning the gate it
reuses startOnDemandMetadataRefresh, whose scheduled mode merges fill-empty,
so this non-admin trigger cannot clobber unlocked admin edits while found
videos still persist.

The handler checks item access before calling the service, so an unauthorized
caller can never burn an item's slot, and rejects non movie/series types since
those detail responses never carry videos. cooldown and disabled are expected
client-rendered states and answer 200; 429 is reserved for the per-user
in-memory limiter.

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

* fix(metadata): release trailer-refresh slot on failed refresh; resolve episode ids to 400

Three review findings on the viewer-facing trailer fetch.

The weekly per-item slot was consumed unconditionally on winning the gate,
but the refresh it started ran detached and only logged on failure — nothing
ever put the slot back. A brief TMDb outage therefore answered 202 queued,
failed 30s later, and then answered cooldown for seven days over work that
never happened. The repository gains an equality-guarded release
(trailers_refresh_requested_at = NULL only while it still equals the
timestamp this request wrote, so a later claim is never clobbered), and
TryClaimTrailersRefresh now RETURNINGs the timestamp it stored so a winner
holds the key to its own slot. startOnDemandMetadataRefresh splits into a
claim step and runOnDemandMetadataRefresh, which takes an optional failure
hook; only the trailer path passes one, so the existing callers are
unchanged. A timeout counts as failure. A refresh that succeeds but finds
nothing still keeps the slot — that semantics was chosen deliberately.

The in-process dedup claim (shared with the item-detail view's stale nudge)
silently dropped the start while the slot had already been consumed, so the
caller was told queued for a refresh that never began. It is now taken
before the durable slot: a request landing while an equivalent refresh is
already in flight reports queued without consuming the slot, which is both
honest and retryable if that refresh fails.

Real episode and season content IDs answered 404 rather than the contracted
400, because neither is a media_items row and GetByID queries media_items
alone. The handler now falls through to the same season/episode lookups
HandleTranslateOnView uses, authorizing through the parent series, so a
genuine episode ID reports unsupported-type and only unknown content 404s.
The type-check test no longer fabricates a MediaItem{Type: "episode"} row
that production never writes; it covers the types that do exist as
media_items rows, with the episode and season paths tested through the
lookups.

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

* fix(metadata): address PR review on the trailer refresh action

Six review findings on the viewer-facing trailer fetch, all verified against
the current code before changing anything.

Durable claim no longer rides the request context. A cancellation landing
after Postgres commits the gate UPDATE but before pgx returns would consume
the item's weekly slot with no refresh started and nothing holding the
timestamp needed to release it. The claim now runs on
context.WithoutCancel with its own deadline, mirroring the release.

The cooldown gate retries once when the follow-up read finds the slot free.
Classification spans two statements, so a concurrent failure-release can
land between them; the old code reported that as a cooldown with no
next_allowed_at while the slot was in fact free. A NULL read now retries the
claim, and the doubly-lost case answers "queued" (an equivalent refresh is
running) rather than an undateable cooldown.

A failed item_videos write now releases the slot. mergeAndPersist logs and
continues when the write fails, so the refresh reported success and the
viewer was locked out for a week having stored nothing. A context-scoped
observer, installed only by this action, surfaces that failure to the
existing release hook.

Winning the gate also records durable refresh debt, so a restart that kills
the detached goroutine leaves work the refresh worker picks up instead of a
consumed slot and no fetch. Uses a new reason bit rather than the generic
failure reason: nothing is wrong with the item, so it must not sit in the
failure band ahead of real debt or count as a failure in operator metrics.

Any library lookup failure now degrades the video-kind scope to unknown. An
item in two libraries where one resolved with trailers off and the other
could not be read reported "disabled" — a guess made on behalf of a library
that might be the one enabling trailers. A library that is genuinely gone is
still skipped.

Adds GET /api/v1/items/trailers/capability, following the existing
per-subsystem probe convention. The action route is registered conditionally,
so "this build has the feature" is not the same question as "this deployment
serves it", and a 404 on the POST is indistinguishable from a missing item.
The probe is registered unconditionally and answers refresh:false when
unwired.

Not changed: content-ID canonicalization mid-refresh stranding the cooldown
on the old row. The re-anchor path is manual-refresh only and this action
runs in scheduled mode, so only local-skeleton promotion can fire, and the
rename carries the timestamp and the debt row to the new id along with
everything else.

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

* style(web): format overlays schema after merging main

The line came in over-length from main's card_overlays merge and the Web
CI format check runs prettier across all of src, not just changed files.

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

* fix(metadata): address second review round — recovery-debt lease, locked-videos preflight, shared limiter

The restart mitigation added in the first round reintroduced two of the
problems it was closing, and the reviewer was right to push again.

Lease the recovery debt behind the fast path. The row was enqueued due
now, so refresh_metadata could claim it while the detached goroutine was
still running the same refresh — RefreshScheduledTarget does not consult
the in-process claim, so both would fetch the item at once. It is now due
5 minutes out, comfortably past the 2-minute on-demand timeout, and the
goroutine settles the row on success so it fires only when the fast path
really did not finish. Settling clears just the trailers-requested bit,
keeping any real debt the item still carries.

Release the cooldown after a failed recovery. A recovery runs in a worker
that never saw the claim, so a failure left the viewer blocked for the
week having stored nothing. RefreshScheduledTarget now adopts the claim
when the debt row carries the trailers-requested reason, reading the
stored timestamp so the release stays equality-guarded, and hands the
slot back on the same failures the fast path's hook covers — including a
videos write that failed and was only logged.

Preflight the videos lock. locked_fields containing FieldVideos makes
mergeAndPersist skip the item_videos write, so the refresh "succeeded"
and kept the cooldown while never being able to save trailers. It now
answers disabled before consuming the claim; reusing that status rather
than adding one is deliberate, since clients treat an unknown status as a
dead end and "trailers cannot be fetched for this item" is what disabled
already means to a viewer.

Use the shared limiter. A private MemoryLimiter gave every instance an
independent per-user allowance on Redis deployments, and the per-item
cooldown cannot compensate — it bounds one item, while this budget bounds
how many distinct items a user can start refreshes for. The action now
takes the middleware's configured limiter, with namespaced keys, and
falls back to a private one only when rate limiting is off.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:53:21 -04:00
97669d52d4 fix(settings): declare ui.card_overlays for native clients (#530)
The Apple and Android clients are moving onto the canonical
ui.card_overlays key (silo-apple#118, silo-android#158), so the
manifest's advisory platforms metadata now lists them alongside web.
Revision bumped to 4; Go/TS bindings and the conformance fixture
regenerated. Client-repo bindings will pick the bump up on their next
`make settings-bindings` run — nothing else in the manifest changed.

Also stops the web client from erasing the two schema-legal ribbon ids
it does not render yet: buildItems() rebuilt `items` from the web
registry alone, so a stored `imdb_top_250` / `rt_certified_fresh`
config (authorable from Android's native editor, and valid per
card-overlays.json) vanished from both `items` and `order` on the next
web save. Those ids now pass through parse/serialize untouched, on the
same defaults the native registries use, while remaining invisible to
web rendering and settings.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:54:42 -04:00
QuickandClaude Fable 5 7aee29be7b feat(settings): split web quality picker into preferred quality and max bitrate
The web Playback screen compressed playback.preferred_quality and
playback.max_bitrate_kbps into one preset picker while the device screen
already edited them as two controls, so a device override never read as an
override of anything the user could see. Replace the preset picker with the
same two rows, backed by a shared bandwidth ladder in lib/bitrateOptions
(rungs widen above 20 Mbps up to the contract's 200 Mbps ceiling; "No limit"
clears the rows rather than storing a sentinel). qualityPresets.ts is retired
with its test.

The bitrate cap now also does something on web: WatchPlaybackChrome reads it
alongside the resolution cap and the player keeps the HLS startup tier under
it, composing with the resolution preference. A direct-play base is not
restarted to enforce it, and an explicit in-player pick still wins.

Verified against a live backend: writes land as typed JSON at profile scope,
No limit deletes the row, and stored off-ladder values stay selectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 01:49:41 +00:00
Quick c6383aa4dd fix(settings): mark sqlite user database unavailable 2026-08-01 16:17:17 +00:00
QuickandClaude Fable 5 a28960819d fix(settings): make device setting controls work and fit the device
The device settings screen shipped with three defects:

- Sliders (audio sync, playback speed, subtitle sync, next-up prompt,
  sleep timer) were rendered controlled with only onValueCommit, so the
  thumb never moved and no gesture could change the value. A shared
  SettingSlider now keeps a draft during the drag and commits once on
  release; the admin RegistrySettingControl had the same bug and uses it
  too.
- "Change how they look" called an onOpenPanel the page never passed.
  The screen now opens the shared SubtitleAppearancePanelView scoped to
  the selected device and profile, with a reset that clears the row.
- Every setting was shown for every device, so a browser offered
  "Screen orientation" and native-only toggles. settingsgen now emits
  the manifest's advisory platforms field into the TypeScript contract,
  and the screen hides settings whose platforms exclude the target
  device — unless the device stores a value, which must stay clearable.
  audio_sync_ms, dolby_vision_enabled and dv_profile7_hdr10_fallback
  gain native-only tags (no web consumer exists); platforms is advisory
  UI metadata, so no revision bump.

Kotlin/Swift generators deliberately unchanged: the native clients
hardcode their own applicability today, and their vendored bindings are
regenerated from their own repos.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:15:51 +00:00
9aab2ead57 feat(settings): add user-facing device settings (#527)
* feat(settings): let users manage device settings across their devices

Adds the server half of the user-facing device settings screen: a viewer can
see the devices they watch on and change settings for any of them from
whichever device they are holding, and the household parent can do the same for
everyone on the account.

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 this is authorization plus routes.

Two identity widenings on the canonical settings API, each behind a guard:

- A caller may name a device_id other than the request's own. Authorized
  against user_devices for that profile, which is why DeviceExists lands
  first: completeIdentity validated an identity's shape but never that the
  device belonged to the caller, safe only while the id came from the header.
- A household parent may name a profile_id other than their own. Guarded by
  canManageHousehold, extracted from ProfileHandler so profile management and
  settings management cannot drift apart. Existence resolves through the
  caller's own store, so a foreign profile is 404 and the cross-account
  boundary holds for free.

Both default to today's behavior when the parameter is absent, so existing
clients need no change.

New self-service routes: GET /devices, DELETE /devices/{id}, and
DELETE /devices/{id}/settings. The list filters to the calling profile in the
handler — ListDevices is account-wide by construction in both backends (no
WHERE at all in the per-user SQLite), so a passthrough would have shown every
household member's devices to everyone. ?scope=household is opt-in and guarded.

Also fixes a bug the widening exposed: registerWritingDevice fired on every
device write, so writing to another device — or on another profile's behalf —
would have registered the actor's browser under the target, inventing a device
nobody holds.

Cross-profile and admin mutations are audited. The record carries identity
only, never the value, for the same reason user_settings.changed does: admins
receive other accounts' events. Ordinary self-service writes are not audited —
a trail that records everything answers nothing.

Part of #215

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

* feat(settings): add the user-facing device settings screen

Adds "Your devices" under Settings: a searchable device list and an editable
detail pane, so someone can fix how Silo behaves on any device they watch on
without borrowing that device. The household parent gets an "Everyone" switch
covering every profile on the account.

Layout is master-detail, and holds at eleven devices: fixed-height rows
carrying a name, when it was last used, and the one number that matters — how
many settings differ there. A device with nothing changed shows a dash rather
than a zero, so "which one did I change?" is answerable by scanning. Rows group
by recency, or by person in the household view.

Settings are grouped by what they affect — Picture, Sound, Subtitles,
Episodes — rather than in manifest order, and no raw key is ever shown: labels,
descriptions, controls, bounds and options all come from the contract. A test
asserts every device-scoped key lands in exactly one group or is deliberately
hidden, so a key added to the manifest cannot silently vanish from the screen.

Values round-trip as typed JSON rather than through strings, unlike the admin
console: a slider re-parsed from text is a hazard on a screen a viewer drives.

Policy caps are explained rather than hidden. A capped setting renders only the
permitted options and says which value the household limit displaced; a locked
one says so instead of presenting a disabled control with no reason.

Acting for someone else is stated, never implied — a persistent banner, and
reset actions that name the person ("Use Robin's setting"). The household view
also states what it does not show: this is how Silo is set up per device, not
what anyone watched.

Two fixes the work turned up:

- effectiveSettingsQueryKey was namespaced by active profile only, so reading
  another device's values would have collided with the current device's cache
  entry and served one device's settings as another's.
- The settings shell caps content at max-w-3xl, which is right for a single
  column of rows and squeezes a two-pane page. Pages that manage their own
  layout now opt out.

Part of #215

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

* feat(settings): filter the household device list by profile

Adds profile chips above the device list in the household view: Everyone, then
one per person, each with a device count. Picking a person narrows the list to
their devices; picking the active chip again clears it.

The chips only appear in the household view, where more than one profile is on
screen. A viewer looking at their own devices has exactly one profile, so a
filter with a single option would be chrome that explains nothing.

Three details the interaction needs to be honest:

- Counts come from the unfiltered list, so a chip keeps saying how many devices
  it would reveal instead of collapsing to zero once another chip is active.
- Grouping falls back to recency once a person is chosen, because a person
  heading would only repeat the chip above it.
- The detail pane follows the filter. Leaving someone else's device open while
  the list shows another person would make the list and the pane disagree about
  whose settings are being edited — the one thing this screen cannot be vague
  about. Leaving the household view clears the filter for the same reason.

The chip's count sits in its own element, so its accessible name is set
explicitly: without it a screen reader announces "Everyone3".

Part of #215

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

* fix(settings): lead the profile filter with the viewer's own chip

Seeding a realistic eight-profile household made the ordering problem obvious:
chips were in device-arrival order, which put the person actually using the
screen last. Their own profile now comes first and the rest sort by name, so a
chip stays where it was last seen rather than moving as devices are used.

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

* fix(settings): make the device screen work on a phone

The screen was built two-pane and stacked those panes vertically on narrow
viewports, which is the wrong shape for a phone: the device list ran past a
thousand pixels before the first setting, so reaching "turn HDR off" meant
scrolling through every other device to get there. The whole page measured 4726
CSS pixels — 5.6 screens — for four devices.

Below xl the list and the settings are now two screens rather than two panes.
Picking a device swaps to it and a back control returns; the page header and
scope switch belong to the list screen, and the detail screen's own header says
which device it is about. The list page is 1050px, and scroll position resets on
each swap so a tap does not land mid-settings.

Touch targets were 32-36px throughout. Rows, chips, the scope switch, the reset
link and the header actions now clear 44px on a phone and keep their compact
desktop sizing from xl. Device rows carry a chevron below xl, because there they
navigate rather than select in place. The device search input goes to 16px on
mobile — iOS Safari zooms the viewport for anything smaller and does not zoom
back out.

Profile chips wrapped to three lines at eight profiles and pushed the list off
screen; they scroll horizontally on one line instead, the same trade the
settings shell's own mobile tab bar makes. Switches now sit beside their labels
rather than below, saving a row on each of ~18 toggles, while selects and
sliders still take the full width they need.

Two fixes the pass turned up, neither mobile-specific:

- playback.max_bitrate_kbps is declared as an integer range with a select
  control and no members, so it rendered as a dropdown with one blank entry —
  unusable, and silent about the value it was storing. It now offers real
  bandwidth choices bounded by the definition's own range, and keeps a
  non-preset stored value selectable.
- Select triggers had no accessible name, announcing as bare comboboxes.

Forget is destructive and rare, so it no longer sits as a full-width sibling of
the common action.

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

* fix(settings): keep the device list usable at hundreds of devices

A real account carries 260 devices; the test fixtures had 14. Every browser
profile, private window, reinstall and test build registers a device identity
and nothing prunes them, so the list grew without limit — 13,681 CSS pixels,
sixteen screens, and the settings themselves never came into view. That is the
same failure the mobile pass just fixed, at a scale the fixtures never showed.

Three changes:

- The list is a bounded scroll area rather than an unbounded column. The page
  is now ~1,300px whatever the device count, and section headings stick while
  scrolling so the recency or person grouping stays legible.
- Devices nobody has used for 90 days that carry no settings of their own
  collapse behind "Show N unused devices". Over half of the real fleet is that:
  one-off sessions that never changed anything. The current device and anything
  with settings always stay visible, however old.
- Search spans everything including the hidden tail, because searching means
  looking for something specific and hiding a device from its own name would
  read as the device having vanished.

The 90-day threshold matches the settings contract's own rule for removing
empty device records, which is specified but not yet implemented server-side —
until it is, this keeps the screen usable.

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

* fix(settings): offer the whole bandwidth range the contract allows

The bandwidth cap stopped at 40 Mbps because the ladder behind it was a
hardcoded list I wrote, not anything the contract said. The definition allows
up to 200,000 kbps, and remuxed 4K HDR or an untouched Blu-ray rip needs well
past 40 — so the picker was silently capping people below what their own server
could already send them.

The ladder now runs to the definition's own ceiling. Its low end mirrors the
in-player quality switcher, so a cap chosen here lines up with what the player
offers mid-playback, and entries outside a definition's declared range are
filtered out as before.

Also stops duplicating the label format a third time: the player's
formatQualityBitrate is now exported and reused, since both surfaces pick from
the same ladder and should not disagree about how to spell a number.

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

* style(settings): use the app's overlay scrollbar in the device list

The bounded device list kept the browser's default scrollbar, which reads as a
heavy grey slab against a rounded dark panel. The app already has
.overlay-scroll for exactly this — a thin, low-contrast thumb over a
transparent track — and it now sits in the panel's gutter rather than flush
against the rounded edge.

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

* perf(settings): stop effective-values from stalling on live catalog language scans

GET /settings/values/effective computed suggested language values with
three sequential full-catalog scans on every request. On a 439k-item
deployment that took ~25s, dominated by the subtitle listing whose UNION
deduplicated ~5M unnested track rows.

- Deduplicate each subtitle arm before merging (UNION ALL of two
  DISTINCT arms instead of UNION across all rows) and bound the result
  with the facet LIMIT: 22.8s -> ~8.5s of per-arm work on that catalog.
- Cache the observed lists per (list kind, access scope) for 15 minutes
  and collapse concurrent misses with singleflight.
- Run the three lookups concurrently, and cap a cold-cache wait at 2s:
  the response ships with the contract floor while the detached scan
  finishes and fills the cache for the next request.

Part of the device settings screen work; the regression itself shipped
in #526.

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

* refactor(settings): replace track-language catalog scans with picker free entry

Deployment-observed suggestions now decorate catalog.metadata_language
only — original_language is one indexed DISTINCT scan (~0.2s on a 439k
item catalog) and feeds the metadata-exceptions panel, where observed
data is load-bearing. The audio and subtitle track listings (up to tens
of seconds of media-file walking) are no longer queried for settings at
all, which also removes the TTL cache and singleflight added to manage
them.

Those pickers keep the contract's authored floor and gain an explicit
escape hatch instead: a shared LanguageSelect with an "Other…" entry
that accepts a BCP 47 tag, previews the resolved language name, and
refuses invalid tags. The settings are open language_tag values, so a
typed tag needs no server change, and a stored off-floor value already
renders through the current-value merge. The device screen hides the
free entry when policy pins permitted_values.

The browse facet queries keep the subtitle UNION ALL rewrite from the
previous commit; catalog browse still lists observed track languages.

suggested_values on playback.audio_language and
playback.subtitle_language now returns only the contract floor plus the
stored value — Android and Apple pickers need the same free-entry
affordance as follow-up.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:00:15 -04:00
90f5d117c8 feat(events): let websocket clients declare channels on connect (#525)
* feat(events): let websocket clients declare channels on connect

Observing the events hub over `/api/v1/events/ws` costs more than it
should. A read-only consumer has to send a `subscribe` frame within five
seconds or be closed with a policy violation, which means implementing
the handshake and holding the write half of the socket open purely to
satisfy it. That cost is contract, not transport: `subscribe` is the only
inbound message this endpoint accepts.

Accept the selection on the URL instead. `?channels=catalog,user_state`
subscribes on connect, answers with the same `subscribed` frame and
per-channel snapshots the handshake produces, and is never put on the
grace-period clock. A connection that declares nothing is unchanged —
it still owes a subscribe frame within five seconds.

Two supporting changes:

- Channel selection now resolves through one shared function used by both
  paths, so the URL and handshake cannot drift on who may subscribe to
  what. Role, profile-binding, and validity checks are unchanged.
- An unrecognized channel name is reported in the existing `rejected`
  array as `unknown_channel` rather than closing the connection. Closing
  took down every other channel the client held over one bad name, and a
  client cannot always know which channels its role allows before asking.
  Forbidden and profile-scoped channels were already handled this way.

`required_action` in the hello frame is `"none"` for a declared
connection and `"subscribe"` otherwise; the web type is widened to match.
No wire field changes type or disappears, so this stays additive under
the v1 rules.

Part of #523

AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated,
reviewed and verified by the author before submission.

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

* fix(events): address review findings on declared-channel subscriptions

Three findings from automated review, all verified against the code
before acting on them.

**Start the reader before declared-channel snapshots (regression).**
configureWebSocket installs an absolute read deadline that only pongs
extend, and gorilla processes pongs solely inside ReadMessage
(conn.go:950, reached only via advanceFrame). The declared path built its
snapshots before starting the reader goroutine, so a snapshot slower than
the deadline — a loaded jobs/sessions/scans/history query — would kill an
otherwise healthy connection the instant reading began. The handshake
path never had this problem because its snapshots run downstream of an
active reader. Regression test stalls a tasks snapshot past the deadline;
it fails with the previous ordering.

**Advertise the feature through a capability endpoint.** Adding a
client-visible subscription mode without one leaves a read-only client
unable to tell, before connecting, whether ?channels= will be honored: an
older server ignores it and closes the connection after the grace period,
so the client must retain the very handshake this removes. GET
/api/v1/events/capability reports both modes, the grace period the
handler actually enforces, and the known channels, following the existing
per-subsystem convention.

**Deduplicate rejections, not just acceptances.** Asking twice for one
forbidden channel produced two identical `rejected` entries. Pre-existing
— the dedup check sat after the rejection branches — but cheap to correct
in the function this PR extracted.

Part of #523

AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated,
reviewed and verified by the author before submission.

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

* docs(events): frame the capability endpoint as a staleness probe

Clients are expected to run a current build rather than negotiate down to
an old server, so the endpoint is not a branch-on-capability contract.
Its value is letting a client distinguish "this server does not do that"
from "the connection failed" — the two are indistinguishable from the
socket alone, since an older server ignores ?channels= and then closes on
the grace period — so it can tell the user the deployment is out of date
instead of failing opaquely.

Comment-only; no behavior or wire change.

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

* fix(events): bound the subscribed answer and reap unsubscribed connections

Second review pass on the declared-channel path. Four issues, all at the
edges of the new URL surface rather than in the design itself.

Rejections amplified the request. Making an unknown channel non-fatal
removed the brake that used to close the connection on the first bad
name, and every refusal quotes the name it refuses — so a large
?channels= of distinct garbage produced a far larger `subscribed` frame,
buffered server-side. Cap a selection at 32 distinct channels, report the
overrun once instead of per name, truncate an echoed name at 64 bytes,
and set a 64 KiB read limit on the socket so an oversize frame cannot be
buffered whole before it is rejected.

The grace period was disarmed by declaring, not by subscribing. Both
`?channels=` with no names and a non-admin naming only an admin channel
came up subscribed to nothing and were never reaped, each holding a hub
subscriber, two goroutines, and an envelope channel that every published
event fans into. Disarm on holding a subscription instead. Selection now
resolves before the hello frame — it is pure, so nothing moves ahead of
the reader — which lets required_action say "subscribe" when the
connection really does still owe one.

Repeating the parameter dropped channels silently.
`?channels=a&channels=b` honored only the first and reported nothing
rejected. Read every occurrence.

The capability endpoint advertised `plugins`, which is host-to-plugin
runtime dispatch and is granted to no role. An admin following the
endpoint's stated purpose got `forbidden` while already being admin, and
the hardcoded "Admin access required" made it a dead end rather than a
soft failure. Split evt.ClientChannels out of AllChannels, advertise
that, and word the refusal so it does not promise a remedy that does not
exist. A test pins that an admin can subscribe to everything the
endpoint names.

Each guard was verified to bite by reverting it and watching the test
fail.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:59:50 -04:00
QuickandGitHub 73488d1bfa feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
2026-07-31 15:23:30 -04:00
QuickandGitHub d70b291bb8 feat(settings): add shared language option catalogs (#521) 2026-07-30 18:17:49 -04:00
b1b093007c fix(autoscan): stop warning "No paths configured" on runtime-path sources (#519)
The per-source targets summary derived a source's paths only from its path
rewrites or path-shaped config, and marked it unresolvable when both were
empty unless the descriptor declared emits_native_paths. That treats "no
configured paths" as a misconfiguration for every source, but only a local
watcher is handed its roots up front.

A webhook source reads paths out of each delivery payload, and a source with
a bound connection gets them from the provider's root folders — where empty
rewrites are the correct steady state, not an omission. SuggestRewrites
deliberately proposes nothing when a reported root already equals the Silo
path (suggest.go:176), so a correctly configured shared-namespace deployment
lands on exactly the state the row flagged as broken.

Gate the warning on whether the paths could have been known from stored
config at all, and report the rest as unknown. A local watcher with declared
path fields left empty still warns — that is the silent-failure case the
check exists for. Sources that do have rewrites are unaffected.

Fixes #518

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:54:56 -04:00
dc4b9a0909 feat(settings): add the cross-platform settings contract and its manifest (#479)
* docs(settings): define the cross-platform settings contract

Turns the audit in #376 into a decision-complete design for how user settings
work across the server, bundled web client, Apple clients, and Android clients.

Today there are three partial contracts - the server registry, the web client's
own manifest, and independently owned key constants in each native client - and
they have measurably drifted. The root enabler is that keyUsesUserScope returns
true for any unregistered key, so a client can invent a production setting
unilaterally and the server stores it as an unvalidated string.

The design decides:

Ownership. Every production user-facing setting needs a server-owned manifest
entry, even when the value is stored only on one client. The single exception is
private local.<client>.* diagnostics, bounded by five conditions.

Types and scopes. Native JSON values instead of strings. Five remote scopes plus
client_local, and each definition declares its own resolution order rather than
inheriting a global precedence.

Preferences versus restrictions. internal/policy already resolves
max_playback_quality and metadata-language limits over the same controls this
contract resolves preferences for. Definitions declare constrained_by, the
effective response reports the permitted value alongside the user's stored one,
and a mutation exceeding a restriction is stored rather than rejected - a capped
4K preference should take effect the day the cap lifts, not be destroyed by it.

Compatibility. Widening a scope, adding an enum member, or widening a range is
additive and revision-tagged; narrowing anything needs a new key. introduced_in
is a manifest revision attached to individual enum members and scopes, not just
whole definitions, so a newer client never offers a choice an older server will
reject.

Rollout. One coordinated breaking release, with no compatibility shim,
projection, or client fallback. After the cutover no future setting requires
coordination. No settings version check goes in the authenticated middleware and
nothing returns 426: deleting the old routes already produces the break, and a
gate would be more code in four repos for the same outcome while permanently
coupling every endpoint to one subsystem's versioning.

Scope placement. Appearance and date/time move from account to profile scope.
Account scope was an artifact of pre-profile storage; leaving it there means a
household shares one theme and text size, and any non-child profile can restyle
everyone else.

Read path. Batched context resolution, index requirements, a session-snapshot
rule, and a no-regression benchmark gating storage consolidation - profile_series
resolution is per-item, so a season view would otherwise issue one request per
episode.

Verified against the current server, Apple, and Android implementations. Two
findings shape it: the unknown-key extension bag is real, and v1 scope reads NOT
LOCKED, so removing the legacy surface needs no amendment if it lands before
lock.

Related to #376.

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

* feat(settings): add the canonical settings contract manifest

First implementation step for the cross-platform settings contract (#376).
Adds the artifact everything else depends on: the manifest, its JSON Schema,
the object value schemas, and a Go loader that validates the whole thing at
load time. No routes, no storage, no behavior change — nothing reads this yet.

contracts/settings/v1/ holds the artifact at a stable path because clients
vendor it and generate bindings from it. The embed directive has to sit beside
it (go:embed cannot reach outside its own directory), so that directory is a
tiny Go package containing nothing else; loading and validation live in
internal/settingscontract.

38 definitions: 35 remote, 3 contract-known client_local. That covers every key
the legacy registry accepts, every unregistered key the extension bag was
silently accepting from the web client, every unregistered device key Android
writes, and the profile preference columns that become settings.

Registering the previously-unregistered keys is where the drift shows up, and
the manifest records each case in a notes field:

- ui_theme, ui_text_scale, ui_text_weight, ui_high_contrast,
  ui_custom_theme_vars, and ui_custom_css reached the server only because
  keyUsesUserScope returns true for any unregistered key. They are now typed,
  renamed to the dotted convention every other key uses, and moved to profile
  scope per the design.
- player.match_frame_rate and player.sleep_timer_default_minutes are written by
  Android against a server that does not register them, so every write and reset
  is currently rejected. Registered.
- player.next_up_prompt_seconds is Android's alias for
  playback.next_up_prompt_seconds and does not become a definition; the test
  matrix pins it as a migration alias.
- player.playback_speed is capped at 3.0, matching the server rather than
  Android's 4.0.
- subtitle_appearance becomes playback.subtitle_appearance. Every other
  canonical key carries a domain prefix, and preserving accidental key names is
  an explicit non-goal of the design.

Validation is deliberately stricter than the schema can express. Beyond shape,
it enforces that a resolution order ends in "default", that it only resolves
scopes the definition allows, and — the one most likely to bite — that every
writable scope is actually read, so a setting cannot accept writes at a scope it
will never honor. Defaults are validated against their own value schema, so a
default that violates its own range or enum fails at load. Revision tags are
checked to never run ahead of the manifest revision, which is what makes
revision-aware client filtering trustworthy. Ceiling and floor policy
constraints are rejected on unordered types, where capping would silently do
nothing; playback.preferred_quality's enum is therefore ordered ascending.

ValidateValue is the single validation path, so the mutation endpoint, the
migration, and the manifest's own default checks cannot diverge later. Numbers
decode through json.Number so an integer setting rejects 30.5 rather than
truncating, and object values validate against their referenced JSON Schema
instead of accepting arbitrary JSON the way validateJSONSetting does today.

Canonicalization implements RFC 8785 over the value domain the contract uses:
sorted keys, no insignificant whitespace, ECMAScript number formatting. The
digest is the ETag, and PublicBytes strips maintainer notes so the served
manifest never carries internal commentary.

Promotes santhosh-tekuri/jsonschema/v6 from indirect to direct.

Verification: 124 tests pass across 16 cases; golangci-lint clean;
make verify-local-paths passes. Two failures in internal/api/handlers
(TestRemoveJellyfinCompatWebDisablesWebSetting, the playback v3 seek recovery
test) reproduce unchanged on main and are unrelated.

Part of #376.

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

* feat(settings): give ui.theme a device override

Theme joins text scale, text weight, and high contrast as a profile default
with an optional per-device override, resolving profile_device -> profile ->
default. The right theme is partly a function of the screen and the room — a
light theme on a phone in daylight, a dark one on a TV at night — which is the
same reasoning the other three appearance keys already used.

All four appearance settings now cascade consistently, which also means one
rule to explain in the UI rather than "these three follow the device, that one
does not".

ui.custom_theme_vars and ui.custom_css stay profile-wide. They are authored
styling rather than a contextual preference, so a profile's custom tokens still
apply on top of whichever theme a device resolves to. Recorded in the
definition notes because it is a visible consequence: vars tuned against a dark
theme will sit on top of a light one if a device overrides the theme. Widening
those to profile_device later is an additive revision bump if it turns out to
matter.

Part of #376.

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

* fix(web): tag local appearance caches with their owning account

The theme, text scale, text weight, high contrast, custom theme variable
and custom CSS caches in localStorage were untagged, so on a shared
browser a second account inherited the first account's appearance: with
no server value of its own, every fallback resolved to whatever the
previous account had stored, and the leftover `silo-theme` key also
suppressed the admin-configured default theme for the new account.

DateTimeFormatProvider already solved this by stamping its cache with the
authenticated user id and refusing another account's values. Extract that
mechanism into `createOwnedCache` in utils/storage.ts (where key
namespacing lives) and put all three groups behind it, so appearance and
custom theme get the same protection instead of a third copy of the rule.

- Each group carries its own owner stamp. A shared stamp would be unsafe:
  the groups are written by hooks nested inside each other, and effects
  run inner-first, so whichever hook stamped first would vouch for the
  other's still-stale values.
- A null owner (auth bootstrapping, or signed out) still trusts the
  cache, which keeps the warm start and the login screen's last look.
- An unstamped cache is not trusted once an account is known, so existing
  users take a one-time appearance reset on first load rather than a
  chance of seeing someone else's settings.
- When a foreign cache is detected the values are dropped and the empty
  cache is handed to the new account, so a later single save cannot
  re-trust the rest of the previous account's state.

Owner is the user id because /settings is user-scoped server side; it
lives in one helper (`appearanceCacheOwner`) so it can be widened if
appearance moves to profile scope. `shouldLoadApiTheme` is gone: it had
become a synonym for `appearanceCacheOwner(...) !== null` with no callers
left.

Part of #376

AI-use disclosure: implemented with Claude Code.

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

* fix(settings): make the settings contract enforceable and fix the appearance cache

The contract manifest landed as a document nothing checked. This makes it a
mechanism, and fixes the one defect in the change set that hurt users on merge
rather than at cutover.

Web appearance cache. useTheme cleared the cache for any account whose stamp
did not match and never repopulated it — the only writers were the four
user-action setters — so every upgrading user lost their warm start on every
load, not once, and x-large-text and high-contrast users lost theirs too. The
owner-stamp protocol is replaced with per-account key namespacing
(`silo-theme:7`): a foreign value is absent rather than present-and-distrusted,
so nothing has to be deleted, the first account keeps its warm start, and there
is no shared stamp for a second tab, a stale debounce timer, or an out-of-order
effect to race on. Widening ownership to profile scope, which this manifest
requires, is now a change to appearanceCacheOwner alone. Adds the API-to-cache
mirror useTheme was missing, cancels pending debounced writes across an account
change, and re-seeds provider state during render so no frame paints the
previous account's look.

Canonicalization. writeCanonical used json.Marshal, which HTML-escapes < > and
&, and canonicalNumber used Go's 'g' format — both diverge from RFC 8785, so
the first label containing an ampersand or bound below 1e-4 would have forked
the server's ETag from every conforming client. Output is now byte-identical to
ECMAScript String() across the edge cases, verified against node. The ETag also
covers the value schemas, which decide what the server accepts and previously
could change while the tag stood still. All four derived representations are
memoized; a conditional GET no longer costs a full parse and re-serialize.

Validation. strictUnmarshal's decoder.More() answered false for a stray ] or },
so `true]` validated as a boolean. Enum matching compared fmt.Sprintf tokens, so
the string "3" satisfied an integer member. Declared steps were never enforced.
The language pattern rejected tags both mobile platforms emit unprompted
(en_US, ca-ES-valencia, ar-EG-u-nu-latn) and never normalized case, so en-US and
en-us were two rows for one preference; NormalizeValue now canonicalizes on the
shared path.

Manifest. show_forced_subtitles defaulted false where the server column is NOT
NULL DEFAULT true, which would have turned forced subtitles off for every
profile that never touched it. preferred_quality declared 13 members where the
planner speaks 6 and collapses the rest to auto. metadata_language's allowlist
was bound to the very column it migrates from. subtitle-appearance pinned
fontFamily to three families while Apple stores any installed system font.
Registers five user-facing settings the clients already ship, and corrects three
notes that described Android behaviour that was not true.

Enforcement. The package had no non-test callers, so MustLoad never ran; it now
loads and logs at startup. The inventory test compared the manifest against a
hand-copied map and could not see the drift it named; it now iterates
settingsRegistry and checks defaults too — both verified to fail on injected
drift. Adds .github/workflows/ci.yml, the repo's first CI that runs go test,
go vet, gofmt, and the frontend suite. Known pre-existing failures are named
individually in the Makefile so everything else stays gated and the list can
only shrink.

Part of #135

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

* fix(settings): align the sleep timer default and range with the shipped client

Android is the only client that implements this setting. It clamps to 0..240
and defaults to 30. The manifest said 0..480 with a default of 0, so a
manifest-driven UI would have offered durations no client can store, and every
user who never opened the picker would have had the preset silently turned off
at cutover.

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

* ci: give the new workflow the deps it actually needs

The first run exposed two gaps in the workflow itself. go build ./... fails
without libvips headers, because h2non/bimg binds libvips through cgo and
pkg-config; the Dockerfile installs the same package. And pnpm/action-setup
resolves its version from package.json, but there is no package.json at the
repo root — the packageManager field lives in web/package.json, and a job's
defaults.run.working-directory does not apply to an action's inputs.

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

* test(web): stop the diagnostics download test depending on the Node version

new Response(blob) reads the body through blob.stream(), which jsdom's Blob
does not implement on Node 22 — the version the Dockerfile builds with. The
test passed locally on Node 24 and threw "object.stream is not a function" in
CI. Nothing in it asserts on the body, only that the object URL and filename
reach the anchor, so a string body is equivalent and works on both.

Surfaced by the CI workflow added in this branch, which is the first thing in
this repo to run the frontend suite anywhere but a developer's machine.

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

* fix(build): copy the settings contract into the container build context

Both Dockerfiles copy cmd/, internal/, migrations/ and web/embed.go, but the
manifest lives in contracts/settings/v1 — an embedded Go package that sits
outside internal/ because clients vendor those files. The image build therefore
fails with "no required module provides package .../contracts/settings/v1".

Caught deploying to the dev box. Nothing had built an image since the manifest
landed: the Docker workflow only runs on pushes to main and workflow_dispatch,
and CI's go build runs against a full checkout, so neither gate covers the
container context. This would have broken the published image on merge.

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

* fix(settings): enforce the language-tag and step constraints the manifest declares

A sweep of all 43 manifest definitions against the running server (160 checks:
declared default, both boundaries, and deliberate violations for each remote
key) found two places where the live registry accepts what the contract
forbids. Both are fixed by calling the contract's own validators rather than
adding a second implementation.

playback.audio_language was checked as "32 characters or fewer", so the server
stored "!!!" for a field the manifest declares as language_tag — a value track
matching would then silently never match. It now requires a well-formed tag via
settingscontract.NormalizeLanguageTag. The empty string is still accepted: the
string-only endpoint has no way to send null, and both Android and web send ""
to clear the choice, so rejecting it would break clearing the preference.

player.playback_speed declared step 0.05 and nothing enforced it, so 0.26 was
stored — a value no client's stepper can represent and that every client would
silently snap on the next write. settingscontract.StepAligned is now exported
and used by both the contract validator and the registry, so there is one
definition of "on step" rather than two that can drift.

This gives the contract its first production consumer beyond the startup load,
which is the direction Phase 2 continues in.

Also fixes a genuinely flaky test that the new CI gate would have hit
intermittently: TestRemoveJellyfinCompatWebDisablesWebSetting used t.TempDir as
the install root, but the endpoint returns 202 and its goroutine keeps writing
there after the test body returns, so cleanup tripped "directory not empty"
roughly one run in four. Confirmed pre-existing and unrelated to settings; the
suite now passes six consecutive full-package runs.

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

* fix(settings): keep widened numeric bounds resolvable at older revisions

A bound was one scalar plus the revision that introduced it, which discards
the value it replaced. Widening a maximum from 240 to 480 at revision 3 left
a revision-3 client with no correct answer against a revision-1 server:
honoring 480 offers values that server rejects, and filtering the tagged
bound out leaves the setting unbounded. Since clients are specified to filter
their pinned contract against the server's advertised revision, the bound has
to carry what it used to be.

Bounds now hold their full history, oldest first, and AtRevision hands back
the limit a given peer actually enforces. A bound nobody has widened still
serializes as a bare number, so the manifest reads the same and untouched
entries do not churn the ETag.

Validation gains the rules the representation makes checkable: a maximum may
only grow and a minimum may only shrink, history is strictly ordered, later
entries must say when they arrived, and the first entry cannot predate the
definition. That last rule is the lower bound allowed_scopes already
enforced; the same gap is closed for enum members, which could previously
claim to predate the definition containing them.

Reported by Codex review on #479.

* fix(settings): accept the partial subtitle appearance objects already stored

The schema required all nine properties, but the current API accepts and
round-trips sparse objects — settings_device_test.go stores
{"fontSize":"xxlarge"} and reads it back — and the web client has always
merged whatever it gets over DEFAULT_SUBTITLE_APPEARANCE. Requiring the full
object would have made the cutover migration quarantine preferences users
really set, or block on them.

Every property is now optional and a stored value is documented as a sparse
override merged over the definition's complete default. An empty object is
still rejected: an override that overrides nothing is the same state as no
override, which the contract represents as unset.

Cross-scope resolution is deliberately unchanged. A device override still
replaces the profile's object rather than merging into it, because a device
override means "draw subtitles this way on this screen", not "amend the
profile" — and that is what the server does today.

Reported by Codex review on #479.

* fix(jellycompat): scan the parent directory when a sidecar changes

Autoscan matched scantrigger rejections by comparing RequestError.Message
against literal strings. One of those messages became "Unsupported media file
extension for library type" and the copy in handlers_autoscan.go did not, so
the comparison silently stopped matching.

The effect is user-visible: a Jellyfin client posting a change for Movie.nfo
or poster.jpg gets a 400 and the batch is abandoned, when the sidecar should
have resolved to a scan of the directory containing it. Three tests covered
exactly this and had been excluded rather than read.

RequestError now carries a Reason the caller can switch on. Message stays
prose for the client reading the response — it is meant to be reworded, and
nothing should break when it is.

Also makes two tests honest about asynchronous work. The Jellyfin Web
teardown deleted its install root while the operation goroutine was still
writing to it, where a late write recreates a path RemoveAll already walked
past; it now waits for the operation's terminal state, which required
exporting CurrentWebOperation. And the direct-play If-Range test pinned size
and mtime so ctime was the only remaining validator, then read it back inside
a single coarse-clock tick — it failed about 85% of the time on main for a
reason unrelated to what it tests, and now rewrites until the stamp moves.

With those fixed, GOTEST_KNOWN_FAILURES is empty and gone: make test-go runs
the whole Go suite. The one test that cannot pass yet —
TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion, which
has failed since the commit that introduced it and describes unimplemented v3
planner behavior — carries a t.Skip explaining that where the test is, rather
than a regex in the Makefile.

Reported by CodeRabbit review on #479.

* fix(settings): reject JSON the decoder would otherwise rewrite

Two cases where encoding/json accepts input by quietly changing it, which is
the one thing a contract promising byte-identical agreement between peers
cannot tolerate.

Duplicate object properties. jsonschema.UnmarshalJSON keeps the last
occurrence, so {"fontSize":"small","fontSize":"large"} validated and stored
"large". Which one wins is a property of the parser, not of the contract: a
client generated against a different JSON library can disagree about what it
just sent, and the canonical form cannot represent the duplicate at all.

Lone surrogates. An unpaired \ud800 became U+FFFD and canonicalization
reported success, so the server would issue canonical bytes and an ETag for
an artifact a conforming implementation must refuse — RFC 8785 requires
terminating here. Substitution also means the value read back is not the
value written.

Both checks run before the decode that would hide them, on the shared
decodeJSON path that the manifest, its public projection and every value
schema go through, and again on the object branch of ValidateValue, which
uses a different decoder.

Reported by Codex review on #479.

* ci: gate Go lint on the lines a branch changes

AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go
job ran only gofmt and vet. A change failing the documented Go lint gate
passed all three jobs.

Running the linter as-is is not an option: the tree has ~296 findings today,
which is why this half of `make lint` was never enforced. Blocking every PR
on a cleanup nobody has scheduled gets the gate deleted again, so CI runs
with --new-from-merge-base and only the lines a branch touches have to be
clean. The count can then only fall.

golangci-lint is built from source at a pinned version rather than
downloaded. A released binary refuses to run against a Go newer than the one
it was built with, and go.mod here tracks Go closely enough that the current
release already fails that way on 1.26.4.

.golangci.yml declared version 2 while still using v1's issues.exclude-rules
key. Current golangci-lint ignores it, so the "allow repeated strings and
unchecked cleanup errors in tests" exclusions silently did not apply — 16
findings in test files that the config says to skip. Moved to
linters.exclusions, which `golangci-lint config verify` accepts.

The four lines this surfaced in scantrigger are fixed rather than excluded:
its repeated status codes and messages are now named constants, so one
condition cannot end up worded two ways.

Also drops the workflow token to contents:read and stops persisting
credentials in the three checkouts, neither of which any job needs.

Reported by CodeRabbit and Codex review on #479.

* docs(v1): record the settings removal as a pre-lock exception

The design removes the legacy /api/v1/settings routes and the profile DTO
preference fields, while AGENTS.md states /api/v1 is additive-only and
removals go through Deprecation/Sunset. Read together those contradict.

They do not actually conflict: v1-scope.md scopes the additive-only rule to
"when the scope locks", and the scope is still open, so a removal taken now
is in scope and there is no amendment process to invoke yet. But that
reasoning lived only in the settings design, where nobody checking the API
policy would find it.

v1-scope.md now carries a pre-lock removals table naming what goes and why
waiting is worse, and states the deadline the argument depends on: a removal
listed there must ship before lock or fall back to Deprecation/Sunset.
AGENTS.md points at the table and says to treat an unlisted removal as a
mistake.

Reported by CodeRabbit review on #479.

* fix(settings): clear the remaining review findings

Small, unrelated except that each was raised on #479.

compileObjectSchemas parsed every non-directory file under schemas/ as a JSON
Schema, so a stray editor backup or .DS_Store would panic the server at
startup through MustLoad. schema_ref can only name a .json file; anything
else is skipped.

cmd/silo used MustLoad while the ETag check beside it and every other startup
failure use log.Fatalf. It now fails the same way, so a bad contract prints
an error instead of a stack trace.

TestRegistryDefaultsMatchTheContract called scalarDefault before handling
null, and scalarDefault rejects null as non-scalar — so the subtest skipped
and the comparison after it was unreachable. A nullable contract default
could disagree with a non-empty registry default and nothing failed.
Confirmed by injecting that drift, which now reports it.

The three appearance providers each adapted the auth context to
AppearanceAuth with identical code, putting the shape of auth back in three
places that widening cache ownership would have to find. useAppearanceCacheOwner
now does it once.

useTheme.test.ts cleared storage.KEYS between cases, but appearanceCache
writes namespaced keys and an owner pointer that are not in that list, so
both survived and the suite was order-dependent. It clears the store, as
storage.test.ts already did.

The abs_smart_collection_store comment is reworded rather than given back its
SQL quotes: gofmt folds a pair of apostrophes in a doc comment into a
typographic quote, which is how it became one in the first place.

Reported by CodeRabbit review on #479.

* feat(settings): add canonical typed storage for the settings contract

The cross-platform settings contract needs one typed store behind it before a
resolver, routes or a migration can exist. This adds that storage to both
user-store backends and holds them to identical behavior.

PostgreSQL gets user_setting_values with the scope CHECK constraints, the five
partial unique indexes that enforce one explicit value per identity, and the
covering indexes the one-query read path needs, plus user_setting_mutations for
mutation_id idempotency and the inert user_setting_migration_rejects audit
table. The per-user SQLite store gets the same shape minus user_id, since that
database is already user-scoped.

The UserStore interface grows the typed operations: read one explicit value at
one scope, collect every candidate row for a resolution request in a single
query, upsert with a revision increment, unset, and the idempotency receipt
operations. The resolution read deliberately returns unranked candidates so the
resolver can rank in Go — one query per request, never one per scope, which the
pgx query-count test pins.

Delete behavior is application-enforced. Neither backend can inherit it from
constraints: the SQLite store declares no foreign keys, and library, series and
device columns are not FK targets in Postgres either. Profile deletion cascades
to profile-anchored values while account scope survives, forgetting a device
clears its profile_device values alongside the legacy overrides, and the
library/series purges remove only what is scoped to that entity.

The shared conformance suite covers all of it, including the set-versus-unset
distinction for false, 0, "" and null, so a divergence between the two backends
fails a test rather than reaching a client.

Part of #376

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

* test(settings): pin the settings-value schema constraints in both backends

Completes the storage track. The conformance suite exercises the store API,
which validates identities in Go before any SQL runs — so nothing noticed
whether the CHECK constraints and partial unique indexes actually existed.
The one-time migration writes these rows in bulk without going through the
per-request path, so the schema is the only thing guarding it.

Adds constraint tests to both backends covering every scope's column
requirements, rejection of an unknown scope, a profile that does not exist,
non-JSON values, and each of the five partial unique indexes.

Also clears the lint the storage commit did not get to: sql.ErrNoRows and
pgx.ErrNoRows compared with == rather than errors.Is (which fails on a
wrapped error), an unchecked rows.Close, and repeated fixture literals in the
shared suite now named so a backend that confuses two scope columns fails on
the assertion rather than on a typo.

* fix(settings): close the review findings in the validator and the theme cache

Four defects the existing tests did not reach.

The web theme resolver compared the server's value against the appearance
cache and fell back when they agreed, but the mirroring effect writes the
server's value into that same cache — so the comparison held on the first
render and stopped holding on the second, reverting an explicitly chosen
theme to the default. The server's value is this account's own stored
choice, so it now simply wins. The regression test re-renders rather than
asserting on the first paint, which is why the original one passed.

golangci-lint's exclusions.paths is a path regex, not a directory list, so
a bare `web` also excluded internal/jellycompat/web_component.go,
internal/webhooksync/, internal/notifications/webhook*.go and eleven other
non-test files that were being linted before. Anchored.

json.Number is a string kind, so `"1.5"` unmarshalled into it happily and
Float64 parsed the quoted digits: a numeric setting validated as a JSON
string and NormalizeValue stored the quoted form into jsonb. Rejected.

The lone-surrogate check ran only on the object branch, so a lone surrogate
in ui.custom_css decoded to U+FFFD on SQLite and was refused outright by
Postgres jsonb — the two backends disagreeing about whether the same value
could be stored. Hoisted to cover every type.

The strict language-tag validation this branch added is correct, but it
rejects what the shipped Android client sends; the companion fix is
silo-android 4aeb78b4.

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

* test(auth): stop TestJWT_TamperedToken passing a valid signature

The test overwrote the last character of the signature with "X". An
HMAC-SHA256 signature is 32 bytes, so its base64url encoding is 43
characters and the final one carries only four significant bits — U, V, W
and X all decode to the same trailing byte. Roughly one token in sixteen
was therefore left byte-identical and validly signed, and the test failed
because ValidateToken correctly accepted it.

Measured at 3098/50000 (6.2%) over distinct signatures; it just failed the
Go job on this branch for reasons unrelated to the branch. Flipping a
character in the middle of the signature is 0/50000.

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

* fix(settings): reject raw invalid UTF-8, not just escaped surrogates

The previous commit hoisted the lone-surrogate check to cover every value
type, but that only closes the escaped path. A raw 0xff byte inside a
quoted string — what an HTTP body carries when a client encodes text in the
wrong charset — is not an escape, so the surrogate scan never sees it, while
encoding/json still substitutes U+FFFD and reports success. NormalizeValue
then stores the original bytes, which SQLite's json_valid accepts and
Postgres jsonb refuses: the same backend divergence, reached the other way.

Found by the Codex review bot on the previous commit's own diff.

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

* fix(settings): size the library page state bound to what the web client writes

ui.library_page_state's `search` was bounded at 256 characters. The web
client serializes an advanced library view as URLSearchParams, encoding each
filter rule as three groups[i][rules][j][field|op|value] keys — measured at
216 characters for one rule, 518 for three, 820 for five.

The current endpoint validates this key by checking only that it parses, so
those oversized values are already stored in production. Typing them at the
declared bound would have failed the migration for anyone who had saved a
view with more than one filter rule, and rejected the equivalent write
afterwards.

Raised to 4096, which clears ten rules with room to spare while staying a
real bound. The test pins it against the key shapes
libraryPageSearchParams.ts actually emits rather than a round number.

Reported by the Codex review bot; the lengths above were measured by calling
serializeLibraryPageSearchParams, not estimated.

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

* feat(settings): split quality into two axes and register the orphan keys

Two manifest changes the cutover needs.

**Quality becomes resolution + bitrate.** The legacy ladder values
(1080p-high, 720p-medium, 1080p-8, 420p, 328p) were never a third dimension
— they are a bitrate spelled into the resolution string. The web player
already decomposes them: useTranscodeQuality.ts defines 1080p-high as
{resolution: 1080p, bitrate: 10000} and sends the two separately, so the
compound form never reached the wire. Downloads went further and kept only
a bitrate ladder.

So playback.preferred_quality keeps the six clean resolutions and
playback.max_bitrate_kbps becomes the second axis, nullable because
"uncapped" is a real answer and a numeric sentinel would need widening
every time hardware improves. Clients compose their own presets from the
pair, which means retuning what "High" means is a client release rather
than a contract break. Migration decomposes each legacy value losslessly,
so none of them lands in the rejects table.

**The five extension-bag keys are now definitions.** card_overlays,
next_up_mode, sidebar_pins, disabled_library_ids and library_order reached
the server only through the unknown-key path, stored as unvalidated
strings. Two of them the server reads back — next_up_mode decides home
section assembly and card_overlays falls back to an admin default — so
they cannot be demoted to client-local. Registering them is what lets the
extension bag close.

Adds three schemas for their shapes and a test that exercises every
schema_ref against a real value: each of these is nullable with a null
default, so the existing default-validation test returns at the null branch
without ever compiling the reference.

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

* feat(settings): add the canonical resolution engine

One answer to "what is this setting, for this profile, on this device, for
this content". Before this, each caller carried its own ladder:
catalog/detail.go resolved subtitles across four levels by hand and audio
across three, handlers/settings.go had a two-level device/user resolution
with a lazy write-back inside a GET, and jellycompat read profile columns
directly. Those disagreed about precedence, which is the drift the contract
exists to remove.

Resolution is one batched read regardless of how many keys, libraries, or
series are in play — ranking happens in Go against each definition's
declared resolution_order. Five sequential index lookups per key per item
is the implementation the design rejects, and a season view is exactly
where it would have shown up.

An absent identity drops its scope rather than erroring, so one code path
serves an identified client, an anonymous jellycompat seed, and a batch
spanning many series. Rows for a foreign profile, device, library or series
are ignored even though the batched read returns them.

Constraints narrow without destroying: a capped 4K preference resolves to
the cap, reports itself constrained, and keeps the authored value so it
takes effect the day the cap lifts. Two cases needed care — null on a
nullable numeric means unbounded, so a ceiling must cap it rather than rank
it equal and let the value that most needs capping slip past; and an
allowlist falls back to a permitted member rather than the definition's
default, which may itself be outside the list.

Adds ValueSchema.CompareValues to the contract package, since ordering
values is what makes a ceiling or floor mean anything and value semantics
belong with the schema that declares them.

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

* feat(settings): add the one-time migration planner

The conversion rules from legacy settings storage to canonical values, as
ordinary Go rather than twice in two SQL dialects. Both backends read their
own rows, hand them to Plan, and write what comes back — so the decisions
are testable without a database and SQLite and Postgres cannot drift apart
in what they decide.

The rules that needed care, each pinned by a test:

Column defaults are not choices. quality_preference is NOT NULL DEFAULT
'1080p' while the contract defaults to auto, so migrating the column
unconditionally would pin every profile in the install to 1080p having
never chosen it — and that stored value would then outrank the contract
default forever. Same for language 'en', subtitle_mode 'auto', and
show_forced_subtitles true.

The empty string is unset, not a value. The legacy string API had no way to
send null, so both Android and web spell "clear my choice" as "". Storing
that would make a cleared setting outrank the default.

Legacy quality decomposes rather than rejects. Every compound value maps to
a resolution and a bitrate from the ladder in useTranscodeQuality.ts, so
nothing lands in the rejects table.

Account rows fan out to every profile, which is the account-to-profile move
the contract makes for appearance and search scope: a household that shared
one theme each end up owning theirs.

Legacy strings become typed JSON — "true" to true, "30" to 30 — or every
generated binding would fail to decode what the migration wrote.

Nullability differs per backend, so profile columns arrive as pointers and
the caller resolves "chose the default" versus "never written" when it
reads. jellycompat's DisplayPreferences blobs ride the same table under
synthetic keys and are left alone; they are that subsystem's storage.

Everything that cannot convert is recorded with a reason rather than
dropped, and a final test asserts every planned row would be accepted by
the mutation endpoint's own validation.

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

* feat(settings): run the one-time migration on the SQLite backend

Wires the planner to real storage as userdb migration V15. V14 created the
tables; this fills them.

It runs inside runMigrations' existing transaction, so a database either
comes out fully migrated or untouched — a partial migration is the one
state neither the operator's backup nor a rollback covers. Pinned by a test
that rolls back and asserts nothing was left behind.

Two things the wiring had to get right that the planner could not see:

Reject identities are JSON. Postgres declares that column jsonb NOT NULL
and SQLite guards it with a json_valid CHECK, so the free-form
"profile=p1 device=d1" the planner emitted would have failed to insert — on
exactly the rows the table exists to record. They are structured documents
now, which is also queryable.

Subtitle and audio preferences are two tables keyed the same way, so they
merge into one per-series record before planning. Converting them
independently would have produced two rows racing for the same identity.

Every legacy read tolerates a missing table, since this runs against
databases created at any schema version, and preferred_metadata_language is
deliberately absent: that column exists only in the Postgres schema.

Tested end to end against a real database rather than only through the
planner — the rows land, satisfy the scope CHECK and the partial unique
indexes, and hold valid JSON. Also covers the empty-install case and
asserts a second run fails rather than silently doubling every value.

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

* feat(settings): run the one-time migration on the Postgres backend

The mirror of userdb V15, registered with goose as a Go migration rather
than SQL: the conversion validates every value against its own definition
and re-encodes it as typed JSON, and one legacy quality string becomes two
rows — neither is expressible in SQL without duplicating the manifest. The
rules stay in internal/settingsmigrate, so the two backends cannot disagree.

RunTx, so the whole backfill lands in goose's transaction. The down
migration empties the canonical tables; the legacy ones are never touched
by the up, which is what keeps the cutover reversible until the follow-up
migration drops the superseded columns.

preferred_metadata_language is read here and only here — the column exists
in this schema and not in SQLite's, so this is the sole source for
catalog.metadata_language.

Verified against a real Postgres: the full goose chain runs, 1080p-high
decomposes to ("1080p", 10000), values land as typed jsonb rather than
strings (jsonb_typeof reports number), rejects carry a queryable jsonb
identity, and the composite profile foreign key refuses a row naming a
profile that does not exist.

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

* feat(settings): add the canonical settings API

The routes that make the typed storage reachable. Until now the manifest,
the resolver and the migration all existed with nothing able to call them.

GET /settings/contract serves the public manifest behind an ETag — clients
vendor a pinned copy and generate bindings from it, so the common request
asks "still the same contract?" rather than transferring it. Its
capabilities sibling reports revision and supported scopes for feature
detection instead of version sniffing.

/settings/values/{key} reads, writes and clears an explicit value at one
named scope, which is what a reset affordance needs: "did I set this here"
is a different question from "what applies", and the old endpoint could
only answer a blurred version of both. Scope comes from the query while
profile and device come from session headers, so one profile cannot address
another's settings by naming it.

/settings/values/effective resolves any number of keys in one request, with
the resolution ladder and the source of each answer reported so a client can
offer "reset this device's override" against the exact row holding it.
Asking for no keys returns every remote setting, which is what a settings
screen wants.

Writes are idempotent when a client sends X-Silo-Mutation-Id: a retry after
a dropped response replays the receipt, and reusing an id with different
content is a conflict rather than a silent overwrite of the wrong thing.

Three things the string-only endpoint could not do, each pinned by a test:
an unknown key is refused rather than stored in the extension bag, values
are checked against their declared type and range, and a write to a scope
the definition does not allow is rejected.

Registered before the catch-all /{key} routes, which would otherwise
swallow "contract" and "values" as setting names. The legacy endpoints stay
live for now; deleting them is the next commit, once their consumers move.

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

* feat(settings): generate typed bindings for all four languages

One generator rather than one per repo. The point of the contract is that
four codebases agree on keys, types, scopes and defaults, and four
independently written generators would be four chances to disagree.

Go and TypeScript land in this repo; Kotlin and Swift are written into the
sibling client checkouts, skipped with a note when they are not present so
a server-only developer can still run it. Output is sorted by key so an
unrelated manifest edit does not produce spurious diffs.

The Kotlin output is the interesting one: it generates the DeviceSettings
allowlist Android maintained by hand, plus the BOOLEAN_KEYS/INT_KEYS/
DOUBLE_KEYS classification it kept as a *second* hand-maintained table that
had to agree with the first. Both are manifest questions now, so the whole
class of "wrote a local key to the server" and "flushed a value the store
could not parse" bugs stops being possible by construction.

The TypeScript output carries the full definition table — labels, controls,
enum members, bounds — so web/src/lib/settingsManifest.ts can be deleted
rather than kept in sync: it declared 17 definitions against the contract's
49, with its own two-scope model that does not match the contract's five.

make verify-settings-bindings fails when the committed output disagrees
with the manifest, wired into CI, so a manifest change cannot merge leaving
every client reading stale keys.

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

* feat(web): add the two-axis quality picker and typed settings hooks

Quality becomes one picker over two stored values.

The server holds a resolution cap and a bandwidth cap independently, which
is what the player has always sent on the wire — useTranscodeQuality.ts has
decomposed 1080p-high into {resolution, bitrate} for as long as it has
existed. Presets live in the client rather than the contract so retuning
what "High" means is a one-line edit here instead of a contract change four
codebases have to agree on, and an older server keeps working because it
only ever sees the two axes it already understands.

A combination no preset covers still gets a truthful label rather than a
picker showing the wrong entry: reachable by setting the axes separately
through the API, or from a legacy value whose bitrate is off this ladder.
Choosing an uncapped preset clears the bitrate rather than storing a
sentinel, so "no cap" stays the absence of a value at every layer.

Adds hooks over the canonical API alongside the legacy ones rather than
replacing them wholesale — a key that is not in the manifest cannot be
expressed, because SettingKey is generated from it, and the default for an
unset value comes from the generated table rather than a literal at the
call site. That last part is what stops the flip-off bug the Apple client
carries a hand-written guard for.

A test asserts every preset composes values the contract actually accepts,
so a preset naming a resolution outside the enum or a bitrate outside the
declared bounds fails here rather than 400ing when a user picks it.

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

* feat(settings): resolve catalog playback preferences through the contract

catalog/detail.go held the two hardest ladders in the codebase: subtitles
resolved across four levels by hand, audio across three, each partially
overriding the last through Has* flags. Both now call the canonical
resolver, so the precedence lives in the manifest and this file cannot
disagree with the contract about which override wins. Adding a scope is a
manifest change rather than another branch here.

The subtitle track signature stays on its specialized table — it identifies
a concrete track rather than expressing a preference, so it is not a
setting.

Resolution keeps the memoization the old lookups had: the audio resolver
still reads once per profile and once per library rather than once per
file, which is what kept a many-track audiobook detail page fast. The test
that guards it now counts resolver reads instead of GetProfile calls, since
the guarantee is about scaling with file count rather than about which
method does the reading.

Four tests seeded the profile column directly. That column is a migration
source now, not a read path, so they seed the canonical value instead —
they were passing against storage nothing reads.

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

* feat(settings): close the unknown-key extension bag

keyUsesUserScope returned true for any key the registry did not know, so a
client could invent a production setting unilaterally and the server stored
it as an unvalidated string. That is how six ui.* settings and five orphan
keys reached production untyped, and it is the root enabler the design
names.

An unknown key is no longer a user setting, so the legacy write path
rejects it and the canonical API — which validates every value against its
own definition — is the only way to store something new.

jellycompat's DisplayPreferences blobs ride the same table under synthetic
keys and keep working: they are that subsystem's storage rather than user
settings, and they move to dedicated storage in the follow-up rather than
being dropped here.

Also repoints the DisplayPreferences seed at the canonical resolver.
Resolved at profile scope with no device on purpose — Jellyfin clients do
not carry Silo's device identity, so a device override leaking into the
seed would hand one device's settings to every Jellyfin client on the
account.

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

* feat(settings): enforce viewer quality caps through resolver constraints

constraintsFor was the unwired half of the preferences-versus-restrictions
seam: it returned nil, so a profile capped at 1080p by policy still resolved
its stored 2160p preference at face value through the effective endpoint.

The settings routes are mounted inside RequireViewerAccess, so the resolved
access scope is already on the request context. Scope.MaxPlaybackQuality
holds a literal member of the contract's quality enum ("1080p"/"2160p"),
which is exactly what the manifest binds playback.preferred_quality's
ceiling to under policy_input "max_playback_quality" — so the wiring is a
direct map with no translation table. An empty value means the policy sets
no cap, expressed by returning nil so the resolver leaves the preference
alone.

catalog.metadata_language deliberately stays unconstrained: the manifest
notes record that the allowlist draft was circular (the policy input it
would bind to is populated from the very preference it would narrow).

The handler test covers both halves of the seam: a 2160p preference under a
1080p cap resolves to the cap with constrained:true/ceiling and the authored
value reported in stored_value, the stored row itself is not rewritten, and
an uncapped viewer gets the preference unchanged with no constraint noise.

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

* feat(settings): publish user_settings change events

Add a user_settings realtime channel so clients learn when a setting
changed on another device without polling. The channel is modeled on
user_state: non-admin subscribable, per-user addressed envelopes, null
snapshot.

SettingValuesHandler gains an EventsHub and publishes
user_settings.changed after every successful PUT and DELETE on
/settings/values/{key}. The payload carries only key, scope and
profile_id — never the value. Admins receive every user's user-scoped
events, so a value in the payload would leak private settings to
admins; interested clients re-fetch over the scoped REST API instead.
The payload is always non-empty because an empty Data falls back to a
null snapshot in the hub. A nil hub (tests) skips publishing.

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

* feat(settings): sweep expired mutation receipts daily

Setting-mutation idempotency receipts were written with an expires_at that
nothing enforced, so the table grew forever. Add a hidden daily system task
(05:00) that walks every login account, opens its user store, and calls
DeleteExpiredSettingMutations. A user whose store fails to open or sweep is
logged and skipped so one broken store cannot stall retention for everyone
else; the delete is idempotent, so the next run repairs anything missed.

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

* refactor(settings): resolve metadata language canonically in access and policy

Repoint the last legacy column readers onto canonical contract resolution
(settings cutover task A4a):

- access.Resolver and policy.ViewerResolver now resolve
  catalog.metadata_language through settingsresolve (profile scope ->
  contract default) via a shared access.PreferredMetadataLanguage helper,
  instead of reading user_profiles.preferred_metadata_language. Resolution
  is deliberately unconstrained: the policy input this preference feeds is
  the one a constraint would have to reference, which is circular — see the
  key's manifest notes.
- playback start now resolves playback.audio_language canonically for the
  profile default instead of reading user_profiles.language, matching the
  catalog detail path. Series and library override handling is unchanged.
- items.go needed no change: it already consumes the resolver-produced
  scope.PreferredMetadataLanguage.

The legacy columns keep their values but are no longer read on these
paths; a profile with only a column value now resolves to the contract
default, and a stored canonical value wins. Tests pin both directions in
access, policy (including scope parity, where the column is now a decoy),
and the playback handler. Read cost is one batched store read per
resolution, same as the profile-row read it replaces.

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

* refactor(jellycompat): give DisplayPreferences its own table

The Jellyfin DisplayPreferences blobs rode the legacy user_settings
key/value table under synthetic jellycompat:* keys, which forced the
legacy settings API to carry a prefix carve-out in its otherwise-closed
unknown-key gate. They are the compat subsystem's storage, not user
settings: the contract neither validates nor resolves them.

Move them to a dedicated jellycompat_displayprefs table in both
backends, keyed by (prefs id, client) per user, with the blob stored as
opaque text served back byte-for-byte (deliberately not jsonb, which
would re-serialize it). The data-copy migrations — per-user SQLite V16
and a paired SQL + Go goose migration for Postgres — are transactional
and harmless to re-run, and both drive their key parsing and row
classification from the new internal/jellycompat/displayprefs package
so the backends cannot diverge, following the internal/settingsmigrate
precedent. A jellycompat:* row that does not parse as a DisplayPrefs
key (only ever writable through the removed carve-out) is recorded in
user_setting_migration_rejects rather than silently deleted.

With the last non-settings tenant gone, the jellycompatSettingPrefix
carve-out is deleted: the legacy settings endpoints now refuse
jellycompat:* keys like any other unknown key and never surface them.

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

* feat(settings): serve admin user-settings through the canonical API

Replace the ten string-registry /admin/users/{id}/settings* and
device-settings* routes with the canonical contract surface: one list of
every explicit value the target user has stored across all scopes, and
set/delete at an explicit scope named in the query string.

The admin handlers live on SettingValuesHandler and share the session
routes' implementation rather than duplicating it — the same key/scope
parsing, identity validation, contract scope allowance, value
normalization and mutation-receipt idempotency, factored into
keyedScopeFromRequest/completeIdentity and setValueAt/deleteValueAt.
The only admin-specific parts are the target user coming from the path,
profile and device ids coming from the query (an admin holds no session
claim to the user being inspected, so its named profile is checked to
exist), and change events attributed to the target user so their
clients refresh.

The list is a new UserStore read, ListAllSettingValues, implemented in
both backends and pinned by the shared storetest conformance suite:
the admin surface wants the stored truth (which overrides exist, for a
per-row reset affordance), which no resolution-shaped read answers.

The ten removed routes are recorded in the pre-lock removals table in
docs/architecture/v1-scope.md per the v1 API rules; the web admin
device-overrides page moves onto the new surface in the Phase B
rewrite inside this same unmerged PR.

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

* test(settings): add the cross-platform conformance fixture and its Go and web runners

contracts/settings/v1/conformance.json is the spec's named drift gate: 21
hand-authored cases of {keys, stored rows, context, constraints, expected
effective value + source}, every one executable against the shipped manifest.
They pin the semantics most likely to drift across four resolver
implementations: the full resolution ladder (series > library > device >
profile > default), an absent identity dropping its scopes, foreign-identity
rows never resolving, ceiling caps that report the authored value with
constrained:true, the ordered-enum sentinels (auto below every cap, original
above), null-on-a-nullable-numeric meaning unbounded and being brought down by
a ceiling but ignored by a floor, allowlist falling back to the first allowed
member rather than the (possibly forbidden) default, and
playback.subtitle_appearance resolving device > profile only with the sparse
device object replacing, not merging. Cases may inject a constraint binding
onto a copy of a real definition so constraint kinds no shipped definition
carries stay testable.

The Go runner (internal/settingsresolve/conformance_test.go) resolves each
case through the real resolver against the embedded manifest. The web runner
(web/src/lib/settingsConformance.test.ts) runs the same cases through a new
client-side resolver, web/src/lib/settingsResolve.ts, which mirrors the
server's semantics; the TypeScript bindings now carry each definition's
ordered flag and constrained_by binding so that resolver derives constraint
behavior from the contract instead of hardcoding it. Both runners reject
unknown fixture fields — schema drift in the fixture itself is drift — and
both refuse a fixture authored against a different manifest revision.

The fixture travels with the bindings: make settings-bindings vendors the copy
the web runner reads, and make verify-settings-bindings fails CI when that
copy goes stale. The Kotlin and Swift copies land together with their runners
in the client repos, which will pick their own test-resource paths.

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

* fix(settings): review pass over the phase A stack

Fixes the eight adversarially-confirmed defects the review of the
unpushed phase A stack (40e0f77a..1f2c7fe4) found, each with a test
that fails without its fix.

Writers left behind by the language cutover (high). 22e9d7f1 made
access, policy and playback start resolve catalog.metadata_language and
playback.audio_language exclusively from user_setting_values, but
POST/PUT /profiles — the write path the shipped web UI uses — still
wrote only the legacy columns, so a language change after the one-time
backfill never took effect (a stale backfilled row, or the contract
default, won forever). Profile mutations now mirror their preference
fields into the canonical profile-scope rows through the same contract
validation /settings/values applies (audio, subtitle and metadata
language, subtitle mode, forced subtitles; the empty string clears the
row, matching the migration's unset spelling), publish
user_settings.changed for each row moved, and 400 on a value the
canonical endpoint would refuse. quality_preference is deliberately not
mirrored: the server never resolves the legacy column and the two-axis
picker already writes canonically.

Web admin settings 404s (high + medium). facad78d removed the ten
/admin/users/{id}/settings* and device-settings* routes but shipped no
web changes, so the user-detail settings and device-overrides tabs and
the devices-page override editor were dead. The seven admin hooks now
speak the canonical values API: one list across all scopes feeds both
tabs, mutations address an explicit scope identity, values re-type
through the generated contract (display stringifies for the
registry-era controls), device rows are enriched with device and
profile names client-side, and the removed bulk device reset becomes
per-key deletes that treat 404 as already-reset.

Silent metadata-language degrade (medium). PreferredMetadataLanguage
now logs a warning with the profile and error when contract load or
store resolution fails, so pool exhaustion is distinguishable from "no
preference"; the healthy paths stay quiet.

Displayprefs move data loss (medium). Under READ COMMITTED the blanket
pattern DELETEs in moveDisplayPrefs/unmoveDisplayPrefs could destroy a
row an old-binary instance committed between the SELECT and the DELETE
during a rolling deploy — reproduced against real Postgres. Both
directions now delete only the exact rows they read (rejects restore by
primary key), leaving a late row stranded for a re-run to pick up.

Coverage the review proved missing (medium x3): admin mutations are now
tested to attribute change events to the target user, not the acting
admin (the exact regression passed the whole suite before); the
user_settings websocket channel is subscribed through the real events
websocket, failing if the channel is dropped from either
allowedChannelsForRole or AllChannels; and the conformance fixture
gains three locked-constraint cases (replace, equal-value pass-through,
locked default) so the Go and TypeScript locked branches — previously
executable by no test on either platform — are pinned by the shared
drift gate.

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

* feat(web): read and write appearance and format preferences through the settings contract

Move the four identity-sensitive preference hooks — useTheme,
useCustomTheme, useDateTimeFormat, useSearchMediaScope — off the legacy
string-only /settings endpoints and onto the canonical settings API.
Each surface now reads through one batched useEffectiveSettings call and
writes via useSetSettingValue at scope "profile", matching what the
generated manifest declares: ui.theme / ui.text_scale / ui.text_weight /
ui.high_contrast are profile-scoped with a profile_device override the
effective read already resolves (no device-override UI exists, so writes
stay profile-wide), and ui.custom_theme_vars / ui.custom_css /
ui.date_format / ui.time_format / search.media_scope are profile-wide.
Keys come from the generated SETTING_KEYS table, so a typo'd or
unmanifested key can no longer be expressed.

Because the canonical effective endpoint always answers — resolving
unset keys to the contract default with source "default" — the hooks now
use the source to distinguish "the profile chose this" from "nobody
stored anything". That preserves the admin-default theme layering and
keeps resolved-but-unchosen values out of the warm-start mirror.

ui.theme moving account→profile scope means the appearance warm-start
cache must not be shared by sibling profiles on one account, so
appearanceCacheOwner widens its token from the user id to user id plus
active profile id. Every cache read/write already resolves through that
one function, so no call site could be left behind; the API→cache
mirror, the render-time re-seed on identity change, and the debounced
write cancellation all follow automatically. The ownership tests now
cover profile switches within one account: no theme/text-scale/CSS leaks
between profiles, each profile's warm start survives the switch, and a
debounce armed by one profile never persists under its sibling.

Part of the Phase B settings-contract cutover; the legacy hooks in
queries/settings.ts keep their remaining callers until B4 deletes them.

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

* feat(web): store library, sidebar, and overlay preferences through the settings contract

Phase B2 of the settings-contract cutover: the query-layer preference
stores — sidebar pins, library page state, disabled libraries, library
order, and card overlay prefs — move off the legacy string-valued
/settings endpoints onto the canonical values API, using generated
SETTING_KEYS and each definition's declared scope (profile for pins,
visibility, order, and overlays; profile_device for page state and the
remember toggle).

Values are now written as typed JSON matching the contract schemas
(sidebar-pins.json, library-page-state.json, library-id-list.json,
card-overlays.json) instead of JSON-encoded strings, so the encoding the
migration produced keeps validating. Every parser accepts both the
canonical object value and the legacy string encoding, so nothing breaks
while caches or older rows still hold strings.

Semantics preserved deliberately:
- Sidebar pin toggles keep their optimistic update with the
  revision-guarded rollback, now layered on the effective-settings cache
  entry (effectiveSettingsQueryKey is exported for exactly this).
- The remember-library-pages toggle clears the device override to
  inherit again rather than storing the default, via
  useClearSettingValue; the canonical DELETE's 404 for "nothing stored"
  is treated as already-done, matching the legacy delete's idempotency.
- Overlay prefs keep the admin default / kill-switch layering: the
  contract default null means "no preference expressed", which is what
  lets /settings/overlay-config defaults apply, and only a stored value
  overrides them.
- Library visibility/order keep their optimistic local state with
  rollback on error; ids are normalized client-side with the same rules
  library-id-list.json enforces.

parseDisabledLibraryIDs/parseLibraryOrder collapse into one
parseLibraryIDList (they were byte-identical), and the serialize helpers
disappear with the string encoding. Legacy hooks in queries/settings.ts
stay for the remaining consumers until B4.

Part of the settings-contract cutover (see
docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md).

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

* feat(web): write playback, subtitle, and library preferences at canonical scopes

The settings screens and the player panels were the last web surfaces still
speaking the legacy string API, and each carried its own idea of where a
preference lives. Playback and subtitle behavior wrote profile columns through
PUT /profiles; auto-play and next-up wrote untyped strings; subtitle appearance
went through three bespoke routes that existed only because the string API had
no way to express an object-valued setting per device. All of them now read one
batched effective resolution and write typed JSON at an explicit scope.

Where each preference lands follows the manifest rather than the endpoint that
happened to hold it:

  - Playback and subtitle defaults, and next-up mode, write at profile.
  - Subtitle appearance writes playback.subtitle_appearance at profile_device,
    replacing /settings/subtitle_appearance/effective and the PUT/DELETE pair on
    /settings/device/subtitle_appearance. One hook now owns that value for the
    settings screen, the in-player panel, and the cue renderer, which before
    each parsed the effective response separately.
  - Per-library edits write at profile_library with the library identity, one
    key at a time. The legacy endpoint replaced a composite row, so clearing one
    field meant re-sending the other three and losing any concurrent change to
    them; independent per-key writes have no such coupling, and "inherit" is a
    delete rather than a sentinel.
  - The in-player series choice splits along the line the contract draws:
    language and mode are preferences and move to profile_series, while the
    track index and signature stay on /subtitle-prefs because they identify a
    concrete track rather than expressing a preference.

Controls render from the generated SETTING_DEFINITIONS. The hand-written
registry beside it had drifted — it declared several profile-only keys as device
overrides, and disagreed with the manifest about the bounds of two sliders — so
the display helpers now derive control shape, options, bounds, and the
device-overridable key list from the contract. (Deleting settingsManifest.ts
itself is B4; nothing outside its own test imports it any more.)

Two follow-on fixes fell out of reading the contract rather than the registry.
playback.auto_skip_recap and playback.auto_play_next_preview are declared at
profile_device but only the intro override was ever consulted, so a device
override on either silently did nothing; the player resolves all three now.
And per-library "Original Language" is gone: the contract types these as BCP 47
tags, and the phase-A migration already rejects "original" at profile_library,
so offering it would have written a value the server refuses.

Risk worth naming: LibrarySettings decides "overrides" from the resolved source
rather than by comparing values, which is what keeps three distinct cases apart
— a library row holding the same value as the profile is still an override, and
a library row holding null is an explicit "no subtitles" rather than an absent
choice. A screen that compared values would collapse the first into "inherits"
and the second into "unset".

Part of #135

AI-assisted: authored with Claude Code; reviewed and verified by the committer.

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

* feat(web): refresh settings from the user_settings channel

A canonical settings write reached only the tab that made it. The server
already publishes user_settings.changed on every write and delete, but no
web client subscribed, so a preference changed on a phone or by an admin
sat stale here until a manual reload or the 5-minute staleTime expired.

Subscribe the channel and treat the frame purely as an invalidation
signal. The payload carries the key, the scope and the profile — never a
value, because admins receive other accounts' user-scoped events and a
value there would leak private settings. Marking the value queries stale
lets react-query refetch only what a mounted screen is reading, and a
burst of writes coalesces into one fetch per key rather than one per
event.

A profile-addressed change to a profile other than the signed-in one is
dropped: it cannot alter what this tab resolves. Account-scoped changes
carry no profile and always invalidate.

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

* refactor(web): render settings from the generated contract

web/src/lib/settingsManifest.ts was a hand-written table of labels,
controls, defaults and bounds sitting beside the generated contract, and
it had already drifted: it declared profile-scoped keys as device
overrides, disagreed with the server on the type and range of several
keys, and enumerated a language subset narrower than the one the player
speaks. lib/settingsDisplay.ts has derived all of that from
SETTING_DEFINITIONS since the contract landed, and nothing but the
manifest's own test still imported it.

Delete the manifest and its test. The one piece it owned that the
contract cannot express is the language list — language settings are
typed as BCP 47 rather than as an enum, so there is no member list to
render — which moves to lib/languageOptions.ts and is now derived from
the shared player language list. Two shapes ship: NAMED_LANGUAGE_OPTIONS
for a control that spells its own unset entry, and LANGUAGE_OPTIONS with
the leading "no preference" row for a nullable setting.

The per-library editor's LANGUAGE_OPTIONS re-export goes with it, so
every language dropdown in settings now iterates one list in one shape.

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

* fix(web): show canonical device overrides in admin devices

The device detail panel read its override rows from
GET /admin/devices/{user}/{device}, whose `settings` array still comes
out of the legacy user_device_settings table. The settings-contract
migration folded that table into user_setting_values and nothing writes
to it any more, so an override created since the cutover — including one
the admin had just saved through this very panel — was invisible here,
while the migrated rows stayed visible. The panel's own writes go to the
canonical route, which made the list look like it silently dropped
edits.

Read the overrides from the canonical values API instead, filtered to
device scope and to this device. Both storage generations show, because
the migration moved the legacy rows into the same table. The detail
endpoint is still the source for registration metadata — device name,
owner, which profiles have used it — which is not a setting and has no
canonical equivalent.

The override count and last-updated readouts move to the canonical rows
for the same reason: override_count is computed over the legacy table and
would disagree with the rows rendered underneath it. "Reset all for
device" has no bulk canonical route, so it keeps issuing one delete per
key, now over the keys that actually exist. The reset button also takes
the profile id from the tab rather than from its first row, which a
profile registered on the device with no override yet does not have.

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

* refactor(web): delete the legacy settings hooks

hooks/queries/settings.ts spoke the string-only registry API: every
value a string, scope implied by which function you called, and an
unknown key silently accepted. Phase B moved every consumer onto the
canonical value hooks, and the last importer left was the file's own
test — so both go together, along with the client functions they were
the only callers of.

hooks/queries/libraryPlaybackPreferences.ts goes with them. It wrapped
GET/PUT/DELETE /library-playback-prefs, which LibrarySettings replaced
with profile_library-scoped canonical writes; nothing in web has called
it since. The server route stays for now — the Android and Apple clients
may still use it — but the web type and query keys have no reason to
linger.

settingsKeys keeps only `all` (the prefix the canonical invalidation
targets) and the plugin entries, which are a different system. The
list/detail/deviceDetail/effective builders described the registry's
cache layout and had no remaining callers; effectiveSettingsQueryKey in
settingValues.ts owns the canonical shape.

hooks/useSettingsForm.ts is deliberately untouched: it edits admin
server_settings through /admin/settings, which is a separate surface
from the per-user contract, and has more than twenty live consumers.

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

* fix(web): review pass over the phase B adoption

Phase B moved the web client onto the canonical settings surface. Three
scope mistakes slipped in, all of the same shape: a value written at a
scope no UI can reach, shadowing the one the user can edit.

Auto-play next. The post-roll toggle wrote profile_device while Settings →
Playback wrote profile, and the contract resolves the device row above the
profile row. Turning auto-play off in the player therefore made the
settings switch permanently inert — it saved a profile value the device row
kept shadowing and snapped straight back, with no web affordance able to
clear the device row. Both surfaces now share useAutoPlayNextSetting, which
writes the profile and clears any device row (also the only way a migrated
per-device override becomes reachable). Before Phase B both writers used
useSetDeviceSetting, so they could not disagree; this restores that
invariant at the scope the rest of the Playback screen edits.

In-player subtitle picks. handleSubtitleChanged wrote three canonical keys
at profile_series, the top of the resolution ladder, while "Auto" on the
item page still deleted only the legacy /subtitle-prefs row — so the reset
silently stopped working and the abandoned language kept resolving for
every episode of the series, forever. One of the three,
show_forced_subtitles, was worse: the player has no forced-subtitle
control, so the value it wrote back was the *resolved* one, which for a
viewer who never expressed a preference is the contract default. That
pinned the default above the profile-scope toggle on the Subtitles screen.
The written set now comes from SERIES_SUBTITLE_SETTING_KEYS — language and
mode only, both derived from the user's actual choice — and
useDeleteSubtitlePreference clears exactly that list, so the writer and the
reset cannot drift. show_forced_subtitles still rides the legacy composite
row, which is keyed to a concrete track selection and is not part of the
canonical ladder.

Admin user settings. The tab now lists every non-device canonical row,
which includes the object-valued profile settings (sidebar pins, card
overlays, disabled libraries, library order, custom theme vars). It gated
only on `definition`, and controlKindFor has no `object` branch, so those
fell through to RegistrySettingControl's select — rendering a user's pins
as a one-entry "Unset" dropdown whose only option nulls them. It now uses
the same isStructuredSetting guard the device tab got, routing them to a
raw JSON editor.

Tests: each fix has a test that fails without it, verified by reverting the
fix in place. The auto-play and subtitle tests resolve through
lib/settingsResolve rather than a canned answer, so the scope-precedence
assertions exercise the real ladder.

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

* fix(settings): serve profile preference fields from canonical resolution

PUT /settings/values?scope=profile writes only user_setting_values, but
GET /profiles still served the legacy user_profiles columns. A preference
saved through the canonical API was therefore invisible in every profile
DTO reader on every platform — Apple's shipped build reads exactly those
fields — while profiles_settings_sync.go mirrored one way only, legacy
column write to canonical row.

Serve those five fields (language, preferred_metadata_language,
subtitle_language, subtitle_mode, show_forced_subtitles) by resolving
their canonical keys through the settingsresolve seam at profile scope,
falling back to the contract default rather than to the stale column.
This matches the cutover direction taken everywhere else: the legacy
columns stay written but stop being read, so "clear this preference"
cannot resurface a pre-cutover value the one-time backfill already
converted. The write paths that accept these fields and mirror them are
unchanged; this is read-side only, and the DTO's field names and types
are untouched.

Resolution is batched. A profile list serves the whole household, so
SettingResolutionQuery.ProfileID becomes ProfileIDs and the new
Resolver.ResolveProfiles ranks every profile against one candidate set —
one store read per list request instead of one per profile. Both backends
carry the widened predicate and the shared storetest conformance suite
gains a household case, so they cannot drift on it.

quality_preference stays column-backed: the legacy column is one compound
value while the contract splits it across playback.preferred_quality and
playback.max_bitrate_kbps, so there is no lossless read. The auto_skip_*
and auto_play_next_preview fields stay column-backed too — the sync path
never mirrored them, so their canonical rows can lag the columns.

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

* fix(web): repair CI findings after the main merge

CI runs checks the local loop does not: golangci-lint (not installed
here) flagged two unchecked Close errors in the new websocket test, and
tsc -b (the tests were only vitest-run locally) rejected strict
indexed-access in four test files touched by the review passes. The
merge also brought main's onboarding tour, whose SettingControl wrote
through the legacy useSetSetting hook this branch deletes — it now
writes the canonical scoped mutation, re-typing the tour's string values
through the generated contract like the admin surface does.

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

* style(settings): satisfy the incremental lint pass

golangci-lint reports findings incrementally, so these three surfaced
only after the previous fix: errors.Is for the pgx.ErrNoRows compare
(wrapped errors), and named constants for the repeated "values"
response key and the "usersettings" prefs id goconst flagged.

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

* fix(database): pin the read value when deleting moved displayprefs rows

Under READ COMMITTED the move's DELETE takes its own snapshot, so during
a rolling deploy an old-binary instance could update a jellycompat row
between the migration's SELECT and its delete — and the (user_id, key)
predicate would destroy the newer value after copying only the older
one. Naming the value the transaction actually read makes such a row
survive as a stranded legacy row instead, the same disposition a
late-inserted row already had.

Extends the concurrent-write migration test to commit an update to an
already-read row during the stall and assert the newer value survives.

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

* fix(api): make canonical mutation writes honest under failure

Three review findings on the canonical settings endpoints:

- Idempotency receipts were recorded via defer, so a failed upsert
  still left a receipt and the client's retry replayed a success for a
  write that never happened. The receipt is now written only after the
  upsert lands, and it stores the actual response — revision and
  updated_at included — so a replay is byte-identical instead of a
  reconstruction of the input with revision 0.

- The mutation envelope accepted trailing JSON after the first
  document, leaving the interpreted mutation parser-dependent. The
  decoder now requires EOF after the envelope.

- Resolving a device-aware key without X-Silo-Device-Id silently
  skipped every stored device override and passed the profile fallback
  off as the effective value. The effective endpoint now fails closed
  with 400, matching the write path's existing requirement.

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

* fix(settings): stop the contract rejecting values shipped clients store

Four bounds in the contract were narrower than what a shipped client
already produces, so real stored preferences would fail validation or
be quarantined at migration:

- The BCP 47 grammar rejected extlang tags (zh-cmn) and private-use-only
  tags (x-private) the legacy length-only validator accepted, turning an
  existing 204 into a 400. The pattern now covers both, and
  NormalizeLanguageTag cases a script correctly after an extlang and
  leaves private-use content lowercase.

- subtitle_appearance.fontFamily allowlisted ASCII, contradicting its
  own description: Apple clients store CTFontManager family names
  verbatim and those are routinely CJK. The pattern now excludes unsafe
  characters instead of allowlisting ASCII.

- theme-var-overrides capped CSS values at 128 characters, which real
  multi-stop gradients exceed; the web importer stores them unchecked.
  Raised to 1024.

Plus one tightening the review asked for: card-overlays.order now
declares uniqueItems, matching library-id-list, so an overlay cannot be
rendered twice.

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

* fix(userstore): reject non-canonical identities and bound resolution batches

Three review findings on the canonical settings storage layer:

- SettingIdentity.Validate trimmed ids only to check emptiness, so a
  padded id like " p1 " validated, persisted verbatim, and was then
  invisible to resolution queries, which bind trimmed forms — a
  silently orphaned row. Validation now rejects any id that is not in
  canonical trimmed form, pinned in the shared conformance suite so
  both backends hold the line.

- The effective-values endpoint accepted unbounded library_ids and
  series_ids lists; the SQLite backend expands each id into a bound
  parameter, so a crafted batch could exhaust the host-parameter budget
  and fail the whole resolution. The request boundary now caps the
  combined content ids at 200.

- pickForScope's doc comment promised ties broken "by the most
  specific id in the request order" while the implementation sorts by
  ascending library then series id; the comment now describes the
  actual (deliberately deterministic-only) behavior.

Plus: the pgstore conformance cleanups now assert the ON DELETE CASCADE
they rely on instead of discarding the delete error, so a dropped FK
can no longer leak seeded rows into the shared test database silently.

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

* fix(settings): close the discovery gaps around the canonical API

Three review findings:

- Canonical profile_device writes never touched the device registry, so
  a device that only ever wrote through /settings/values was invisible
  to ListDevices and the admin device surfaces — undiscoverable and
  unforgettable. Device-scope writes now refresh the registry from the
  request's device headers, throttled the same way the legacy route is.

- The contract spec tells clients to probe GET /settings/manifest (and
  /settings/capability), and to read a 404 as "pre-contract server";
  the router only exposed /settings/contract*. The documented paths now
  alias the same handlers.

- The plugin proxy's X-Silo-Theme header came from the legacy
  account-level user_settings.ui_theme row, so a profile's theme change
  through the canonical API never reached plugins and profiles sharing
  an account were indistinguishable. The lookup now resolves the
  canonical profile-scoped ui.theme row (falling back to the legacy row
  for stores the backfill has not covered) using the request's active
  profile.

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

* feat(settings): emit revision metadata in generated bindings and verify the TS one

Two review findings on the generator surface:

- The bindings dropped every introduced_in tag, so a client generated
  from revision N could not filter its pinned contract down to an older
  server's advertised revision — the promised negotiation had no data.
  The TypeScript definitions now carry introducedIn per definition,
  per scope, per enum member, and the full history of any widened
  numeric bound. (Go/Kotlin/Swift emit keys, not definition tables, so
  they only need the Revision constant they already have.)

- make verify-settings-bindings compared only the generated Go file and
  the conformance fixture, so a manifest change could merge with a
  stale web/src/lib/settingsContract.ts. The target now regenerates and
  diffs the TypeScript binding too, through the same prettier config
  the bindings target applies.

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

* chore: drop the accidentally committed settingsgen binary

24ee9952 checked in a 5.5 MB compiled settingsgen alongside its source.
The binary is a local build artifact — cmd/settingsgen is the source of
truth and make settings-bindings runs it with go run.

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

* fix(settings): close the migration planner's data-loss and crash findings

Four review findings on the one-time legacy-to-canonical migration:

- A device holding both player.next_up_prompt_seconds and its
  playback.* rename canonicalized to one identity, and both backends
  insert bare — a unique violation that failed NewUserDB (SQLite) or
  aborted the goose migration (Postgres). Plan now ends with a
  deterministic dedup keyed on the canonical identity; a canonically
  keyed row beats a renamed alias, since the runtime writes the
  canonical spelling first and only best-effort-deletes the alias.

- The four auto-skip profile columns (auto_skip_intro/credits/recap,
  auto_play_next_preview) were never read, so an explicit true silently
  became the contract default false. They now migrate — explicit true
  only, so an untouched false column does not become a choice.

- Profiles with language 'en' emitted no playback.audio_language row
  because the column default was suppressed, but that default WAS the
  effective behavior: the old playback path preferred English, while
  the canonical null default skips language matching entirely. English
  now migrates as an explicit row. The other suppressed defaults stay
  suppressed — their empty-string defaults already meant unset.

- Stored v1 card_overlays documents were quarantined because the
  planner validated them against the v2-only schema; the web parser has
  upgraded v1 at read time all along. The planner now applies the same
  v1-to-v2 upgrade before validation.

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

* fix(settings): delete canonical library-scoped values with the library

The canonical settings schema deliberately has no FK on library_id or
series_id, and the migration comment promised the owning delete paths
would clean these rows up — but nothing called
DeleteSettingValuesForLibrary/-Series outside stores and tests, so a
deleted library left orphaned profile_library preferences in every
user's store forever.

Adds userstore.SettingValuesCleaner, a per-user best-effort sweep in the
mutation-sweeper's mold, and wires it into the library delete job. The
series-side cleanup is exposed on the same cleaner for the scanner's
orphan pruning to adopt; series have no single delete executor today.

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

* fix(settings): keep off-step playback speeds working until cutover

The legacy device endpoint gained step enforcement mid-branch, turning
an existing in-range PUT of 0.26 from 204 into 400 — a behavior change
on a live /api/v1 endpoint before the coordinated break, which the v1
rules forbid. The legacy validator is back to range-only; the typed
mutation endpoint keeps enforcing the manifest's step.

The migration planner now snaps stored off-step numbers onto their
definition's step grid instead of quarantining them: a stored 0.26 is a
real preference, and every client's stepper was going to snap it on the
next write anyway.

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

* fix(settings): serve canonical values to the readers the cutover stranded

Four P1 review findings where the web writes canonical rows the server
never reads — and the legacy keys those readers use are now unwritable,
so the values are frozen and user edits silently do nothing:

- access.DisabledLibraryIDs and the policy viewer resolver read the
  legacy account key while the library screen writes profile-scoped
  ui.disabled_library_ids. Both now resolve the canonical profile row,
  falling back to the legacy key only when no canonical row exists.

- The sections fetcher and handler read the legacy next_up_mode account
  key while the playback screen writes ui.next_up_mode. Same ladder,
  behind one shared sections.NextUpMode helper.

- Profile creation committed the profile and then synced settings
  non-atomically, so a mid-sync failure left a profile the retry could
  not recreate (name conflict) with preferences that read as contract
  defaults forever. The create path now compensates by deleting the
  profile it created.

- The mounted legacy PUT /subtitle-prefs/{series_id} wrote only
  user_subtitle_preferences, but item detail resolves those three keys
  canonically, so a post-upgrade client's "subtitles off" returned 204
  and was ignored. The legacy handler now dual-writes the canonical
  profile_series rows, and its delete clears them.

Plus the migration's disposition for stranded Apple device-scope audio
language rows: nothing read them before the contract, so promoting them
to real overrides would change track selection at upgrade. They are
recorded in the rejects table instead of copied.

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

* fix(web): make canonical settings writes take effect

Three P1 review findings on the web half of the cutover:

- Every profile-default editor reads the resolved value but writes the
  profile row, so a device override — left by the migration converting
  legacy user_device_settings, or written by another client — kept
  shadowing the save and snapped the control back with no affordance to
  remove it. useAutoPlayNextSetting already solved this for one key;
  that logic is now a shared useProfileDefaultWriter used by the
  playback screen, the quality picker, subtitle behavior, and the four
  appearance setters. It only clears when the key is device-scopable
  and the resolved value actually came from a device row.

- The appearance cache only ever grew: when the effective response
  resolved a key to "default" — because another client deleted it —
  the namespaced entry and local state survived and kept winning the
  fallback, so a removal never reached this browser. The mirror now
  runs both ways, clearing only on an explicit default answer (silence
  is not a deletion) and only within the current identity's namespace.
  Custom theme vars and CSS do the same, except while a local draft is
  unsaved.

- The quality picker wrote the canonical two-axis keys while playback
  still derived its cap from currentProfile.quality_preference, a
  legacy compound column the canonical write deliberately does not
  mirror — so choosing a quality changed nothing about what played. The
  watch route and both item-detail pages now read
  playback.preferred_quality, falling back to the profile column until
  the settings read resolves so playback never blocks on it.

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

* fix(ci): repair the type error and the bindings gate's job placement

Two breaks from the previous commits:

- useTheme referenced storage.StorageKey, but storage is a value, not a
  namespace — the Web job's tsc caught what the local incremental
  typecheck had already cached past. Imported the type properly.

- verify-settings-bindings gained a prettier step, and the Go job that
  runs it has no pnpm, so the check failed on its own tooling rather
  than on a stale binding. Split the web half into
  verify-settings-bindings-web and moved it to the Web job, which has
  pnpm; verify-settings-bindings-all runs both locally.

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

* fix(settings): close the second-round review findings

Three from the review of the pushed work:

- The live profile sync omitted auto_skip_intro/credits/recap and
  auto_play_next_preview, which my own change made load-bearing: the
  player now resolves those keys canonically, so a legacy PUT /profiles
  moved the columns, returned 200, and changed nothing about playback.
  All four now mirror on write. The DTO read block keeps its shape —
  clients pin it — and its columns are what the sync keeps current.

- The effective endpoint dropped unknown keys silently, letting a
  client fill the gap with its own vendored default and present a value
  this server would refuse to store. Unknown keys now 404 by name.

- Two sidebar-pin toggles in flight at once could commit in either
  order, and the server upsert is last-write-wins, so the first request
  landing second restored the pre-toggle document. The writes are now
  chained, and each link reads the document when it runs, so a queued
  toggle sends the newest state rather than the one it was queued with.

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

* feat(database): make the settings-contract deploy reversible

Rolling back this release meant restoring a backup, for a reason that
was not obvious: the DisplayPreferences move deletes the jellycompat
rows from user_settings once it has copied them, and the previous
binary reads exactly those rows. An older server therefore starts
cleanly and silently serves defaults, so every Jellyfin client's saved
view preferences look reset.

The down functions were already written and correct — nothing could
invoke them. The backfill and the DisplayPreferences move are Go
migrations registered in-process, so the standalone goose CLI in the
Makefile cannot see them, and the server exposed only --migrate-only
and --migrate-status.

Adds MigrateDownTo, the --migrate-down-to flag, and a make target, plus
a rehearsal test that seeds a legacy row the way the old binary wrote
it, applies the move, rolls back, and asserts the row returns
byte-for-byte.

Documents the ordering in the spec's cutover section, including the two
caveats an operator needs beforehand: take a backup, and the per-user
SQLite backend cannot be rolled back at all — its migrations have no
down path and an older binary refuses to open a newer database, so
those installs restore from backup rather than degrade.

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

* fix(settings): skip legacy rows whose profile was deleted

The dev-server migration aborted on real data:

  writing playback.subtitle_appearance at profile_device for user 1:
  violates foreign key constraint user_setting_values_profile_fkey

user_device_settings carries an ON DELETE CASCADE on (user_id,
profile_id) today, but rows written before that constraint outlived the
profiles they belonged to — that install had 46 such rows across 14
deleted profiles. The planner copied them faithfully and the canonical
table, which declares the same foreign key, refused them; because the
backfill runs in one transaction, the whole migration failed and the
server could not start.

An override belonging to a profile nobody can select is not a preference
anyone can be shown or reset, so Plan now drops those rows rather than
repairing them, recording each in user_setting_migration_rejects so an
operator can see what was left behind. Account-scope rows carry no
profile and pass through untouched.

Verified by replaying that install's 514 device rows through the
planner: 9 rows would have hit the constraint before, 0 after.

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

* fix(settings): address canonical cutover review findings

* fix(settings): address latest review findings

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:52:41 -04:00
Quick 4504d7c68e fix(autoscan): read descriptors from installed metadata, tighten source config
Second Codex review pass. Two P1s meant the descriptor mechanism was dead for
every real plugin; both are verified against the pinned SDK's converter.

Backend:
- The SDK's CapabilityRecordsFromManifest nests a capability's arbitrary
  metadata struct under "metadata" rather than flattening it, so an installed
  plugin's contract lives at metadata.metadata.scan_source. The top-level lookup
  never matched, silently resolving every plugin to host defaults. Look in the
  nested location first, keeping the flat path for host-built descriptors.
- The same converter persists config_schema without admin_form, so a plugin's
  form could never reach the UI through that route. The canonical location is
  now config_form inside the typed block, which survives installation; the
  config_schema path is retained only as a fallback.
- Compatibility merging now tracks which fields the manifest actually declared
  instead of comparing against defaults. A plugin explicitly declaring a
  default-valued field was previously indistinguishable from silence and got
  overwritten, contradicting the stated manifest-wins contract.
- Dynamic-option fields are dropped from source config forms: their options come
  from a connection-aware probe only the plugin-config page performs, so a
  required one could never be satisfied and would block creation permanently.

Frontend:
- The mapping editor asked for "the path your download manager reports" and
  suggested /downloads/tv. arrwebhook.Parse consumes episodeFile.path /
  movieFile.path — the arr's imported library paths, not the download client's
  working directory — so following the example produced rewrites that never
  matched. Relabelled to the Sonarr/Radarr root folder.
- Full-state saves no longer carry an invalid config draft. Enabling, renaming,
  or editing an interval serialized the in-progress draft, bypassing the
  validity gate; unrelated mutations now send the persisted config instead.
- Legacy movie_nested_paths/tv_nested_paths migration is scoped to the CephFS
  identity. Applied globally it would rename an unrelated plugin's
  identically-named key on first save, losing its configuration.
- Rows re-parse when the descriptor arrives after /sources. Mounting with the
  default descriptor left stored switch values as strings, so "false" rendered
  as an enabled switch. Done during render rather than in an effect, and skipped
  once the row is dirty so an in-progress edit survives.
- Values hidden by an unsatisfied show_when are no longer persisted, and
  displayed defaults are. Both come from reusing buildSchemaValues, the same
  helper the plugin-config page uses.
- The manual connection form asks which service it is when a descriptor accepts
  several, instead of always recording the first kind.
- The inline connection picker remounts per source, so a half-entered server is
  not carried into a different plugin's descriptor.
- emits_native_paths sources report unknown targets rather than "can't match
  anything": they discover Silo-native paths at poll time and need no
  configured root.
2026-07-29 14:52:16 -04:00
Quick 4b665add64 fix(autoscan): stop infinite render loop in scan-source config form
Reported from production: React error #185 (maximum update depth) on
/admin/autoscan, thrown from a config form button.

SchemaForm reports validity from an effect keyed on [valid, onValidityChange].
The validity wiring added in the previous commit passed an inline arrow, so the
callback had a fresh identity every render; its setState re-rendered the parent,
which produced another new arrow, and so on. The source row compounded it by
rebuilding the descriptor object inline to hide the built-in webhook provider
field, invalidating SchemaForm's memos on every pass too.

Fixed at both ends. SchemaForm now holds the callback in a ref and reports only
on an actual transition, so no caller can reintroduce this — it is shared with
AdminRequests, which passes an inline arrow as well. The autoscan call sites
additionally memoize the trimmed descriptor and their callbacks, and skip the
state write when validity is unchanged.

Verified in a browser against the descriptor shape that crashed (CephFS with a
required field): no error, and the required-field gating still works. Added
SourceConfigForm.test.tsx covering the loop; without the guard it hangs rather
than completing.
2026-07-29 14:52:16 -04:00
Quick bcea996172 fix(autoscan): address PR review findings
Codex and CodeRabbit review of #515. Each finding verified against the code
before acting; two were declined with reasons recorded below.

Correctness:
- source_config is plaintext JSONB returned verbatim by the source API, unlike
  connection keys which take the repository's encrypted path. Drop secret and
  PASSWORD fields from scan-source config forms rather than rendering a masked
  input over a value stored in the clear. Plugins needing a credential should
  take a connection.
- Plugin-declared webhook delivery is dropped at parse time. resolveDeliveryMode
  accepts webhook only for the built-in ARR identity, so honouring the claim
  offered a setup path whose every submission ended in HTTP 400. The built-in
  supplies its descriptor directly, so it is unaffected.
- Compat lookup now requires plugin id AND capability id. Capability ids are
  author-chosen and not unique, so an OR handed CephFS's path/exclusion form to
  any plugin naming a capability "cephfs".
- Config values stay typed until submit. Stringifying every change turned a
  false switch into the truthy "false" (re-rendering it enabled) and a
  multi-select array into a join the renderer read back as no selection.
- Creation is gated on config-form validity. The host stores source_config
  without interpreting it, so required and validated fields are only enforced
  client-side; the dialog previously ignored the renderer's verdict.
- Row editing now honours the descriptor it was already given: a `required`
  source cannot be unbound, a `none` source shows no picker, and only
  compatible connection kinds are offered.
- sourceTargets skips disabled libraries. The scanner rejects them, so counting
  one as a target showed a source as wired while its deliveries were dropped.
- Inline reuse compares request_integration_id, not connection id — different
  identifier spaces, so the dedupe matched nothing and could create a second
  connection to the same server. Disabled integrations are also excluded, since
  they are rejected at poll time.
- Webhook setup instructions follow the operator's chosen provider rather than
  the descriptor, which advertises both kinds and so always resolved to "auto".
- Webhook rows render their plugin-declared config form (the built-in's
  provider field stays with the endpoint section, not duplicated).

Declined:
- Collapsing sibling paths to a common ancestor is kept. It is what takes a
  real install's 96 library paths to 2 rows. The reviewer is right that one
  rule cannot serve branches the arr exposes under different roots, so the
  editor now offers to split a collapsed row into one row per branch
  (expandedRootsFor) instead of forcing the operator to retype paths.
- Legacy source_config keys are still migrated away on first save; the comment
  claiming both keys are written was wrong and has been corrected to match.

Also: stable ids on mapping rows so removing one does not throw focus into a
neighbouring field, memoized config fields, and descriptor marked optional in
the TS type to match descriptorFor's documented fallback.
2026-07-29 14:52:16 -04:00
Quick 616d262e99 fix(autoscan): keep the add dialog open when webhook generation fails
Found by adversarial review of this branch's own diff. A webhook source is
created enabled and its endpoint generated in a second request. If that second
request failed the dialog closed, leaving an enabled webhook source with no
endpoint — it could never receive a delivery, and nothing on screen said why.

Hold the created source instead and render a retry, so the failure is visible
and recoverable without hunting for the source on the row.
2026-07-29 14:52:16 -04:00
Quick 251282186e feat(autoscan): make source setup descriptor-driven and self-contained
Autoscan setup asked operators to hold five concepts — plugin, capability,
source, connection, delivery mode — before anything scanned, spread across
four tabs. Sources never stated which library they fed, so a misconfigured
one ran cleanly and silently did nothing.

The admin UI also hardcoded two plugin identities: SourcesPanel.tsx carried
33 references to silo.autoscan.cephfs / silo.autoscan.arr-webhook plus a
bespoke CephFS config editor. A third-party scan-source plugin could render
no configuration UI at all without patching silo-server.

Host side, add a ScanSourceDescriptor read from capability manifest metadata:
delivery modes, connection requirement, connection kinds, and the per-source
config form. Capabilities that declare nothing resolve to poll + optional
connection — exactly the pre-descriptor behavior, so existing installs are
unaffected. Compatibility descriptors for the two first-party plugins live in
one file with a clear exit: the manifest always wins, so a plugin takes
ownership by publishing its own.

UI side, the Add-source flow builds its steps from the descriptor rather than
from plugin ids, so a single-mode source is never asked how changes arrive and
a credential-free one never sees the connection step. Connections are created
inline (previously a dead end that forced cancelling out of the dialog), and
webhook setup finishes in one place: mappings, then the URL with the exact
Sonarr/Radarr triggers the host actually parses.

Mapping rows seed from library paths collapsed to their common ancestor per
mount point. Verified against a real install: 96 library paths become 2 rows.
Rewrites match by longest prefix at a segment boundary, so one row per mount
covers everything beneath it.

Fold the Connections and Settings tabs into an Advanced section (4 tabs -> 2);
old ?tab= links land on Sources with it expanded. Source rows now name the
libraries they feed and warn when a source can never resolve one.

API changes are additive within /api/v1: new optional fields on
/autoscan/scan-source-plugins only.
2026-07-29 14:52:16 -04:00
Quick a7601028d9 fix(playback): fail closed on inconclusive PPS scans 2026-07-29 09:49:34 -04:00
CoffeeKnyteandQuick 9281ae61d9 fix(playback): transcode H.264 sources with conflicting in-band PPS
H.264 streams that redefine the same pic_parameter_set_id in-band with
different content cannot be safely stream-copied into an avc1/fMP4 HLS
segment: the avcC advertises a single parameter set, so VideoToolbox
(Safari/Chrome on macOS) decodes with the wrong PPS and desyncs mid-GOP,
surfacing as PIPELINE_ERROR_DECODE / kVTVideoDecoderBadDataErr (-12909).

At playback start, a bitstream scan (DetectMultiplePPSH264) runs for H.264
files on the probe-ensure path, grouping in-band PPS by id and flagging any
id carrying more than one distinct definition. The result is a runtime-only
flag (VideoTrack.MultiplePPS, json:"-") memoized per process — never written
to the database, no schema change, recomputed on the first play after a
restart.

The v3 planner and legacy resolver disqualify a copy-unsafe source from the
video stream-copy / remux ladder, routing it to a real transcode. Direct
play of the original container is left intact: decoders that reparse in-band
parameter sets (ExoPlayer, VLC, native) handle the source fine.

Part of #135.
2026-07-29 09:49:34 -04:00
rxwatcherandQuick f894b150e1 fix(api): return has_more on favorites/watchlist/history so clients paginate
The favorites, watchlist, and history list endpoints returned only
{items: [...]} with no has_more field. Clients gate infinite-scroll
pagination on has_more (absent -> false), so they stopped after the first
page — users saw only the first ~page of favorites and adding one pushed
the oldest off the visible window (reported: Silo-Server/silo-android#56,
affects both phone and TV). The catalog/browse endpoint already returns
has_more; these three were the odd ones out.

Add has_more to itemsListResponse, computed from the RAW store page size
(== limit), not the resolved item count — resolveItems drops
catalog-missing entries and watchlist filters hidden series, so basing it
on the returned length would make a full page look final. Backward
compatible (additive field).
2026-07-29 09:40:53 -04:00
rxwatcherandQuick ad122c38c2 fix(catalog): allocate collision-free subtitle indexes 2026-07-29 09:39:36 -04:00
rxwatcherandQuick bfa5baec86 fix(catalog): give external subtitles their combined-space indexes
buildVersionSubtitleTracks never set Index on external subtitle entries,
so every external sub in a version's subtitle_tracks serialized the zero
value. Any file with two external subs (or one external plus an embedded
stream index 0) published duplicate indexes; clients keying subtitle
rows on index crashed (Compose LazyColumn duplicate key) or resolved
selections against the wrong track. Externals now carry their
combined-space ordinal (externals occupy 0..n-1 in the playback
selection space — the same identity session subtitle_urls and
ResolveSubtitlePolicyV3 use); embedded entries keep their ffprobe stream
indexes unchanged.
2026-07-29 09:39:36 -04:00
CoffeeKnyteandQuick 7907e0c28c fix(playback): composite bitmap subtitle burn-in on the GPU for QSV/VAAPI
Bitmap subtitle burn-in (PGS/VOBSUB/DVB) ran the whole video through a
GPU->CPU->GPU roundtrip: every decoded frame was hwdownload'd to system
memory, the subtitle bitmap composited with the software overlay filter,
then hwupload'd back for the encoder. On a 1080p source that pins the
encode below realtime (~0.68x measured), so the client can never build a
buffer and rides the produced-head edge indefinitely; the same roundtrip
also intermittently crashes the QSV buffer path with SIGBUS.

Composite on the GPU instead via overlay_vaapi for QSV and VAAPI: the
decoded video never leaves its VAAPI surface and only the small,
low-frequency subtitle bitmap is uploaded. Measured ~7x realtime and
crash-free on the same file. The libass text path is unchanged (it must
stay on CPU), and NVENC/CPU keep the software overlay because overlay_cuda
is unverified on the bundled ffmpeg.

Adds QSV and NVENC bitmap burn-in tests and updates the VAAPI test to the
GPU graph.
2026-07-29 09:38:46 -04:00
Quick 824d7bdf43 fix(catalog): preserve normalized TMDB ownership 2026-07-29 09:37:50 -04:00
9f4f9a3bd8 fix(catalog): match TMDB ids stored in TMDB's own id-slug form
Attaching a TMDB id compared the stored value byte-for-byte, so a
media_items row holding "1931-disney-s-adventures-of-the-gummi-bears"
conflicted with the bare "1931" being attached and the presence-lookup
backfill failed:

  WARN requests: failed to backfill tmdb id from presence lookup
    error="media item tmdb id conflict:
           got \"1931-disney-s-adventures-of-the-gummi-bears\", want \"1931\""

That form is not corrupt data — it is how TMDB writes its own URLs
(/tv/1931-disney-s-adventures-of-the-gummi-bears) — and enough of it has
reached the catalog to matter. The visible cost is that a title already
in the library never gets linked, so clients keep offering to request
something you own.

The numeric prefix is the identity, so compare on that. Values with no
leading digits, or digits not followed by a separator, are not TMDB ids
and still conflict as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK
2026-07-29 09:37:50 -04:00
rxwatcherandQuick 08035f3806 fix(admin): validate AI settings drafts 2026-07-28 21:49:41 -04:00
rxwatcherandQuick f0bc170113 feat(admin): clarify AI service configuration 2026-07-28 21:49:41 -04:00
7ab393fc3a fix(jellycompat): resolve item duration probed-first with runtime fallback (#493)
* fix(jellycompat): resolve item duration probed-first with runtime fallback

Jellyfin-protocol clients received no runtime at all for items whose catalog
runtime is 0. RunTimeTicks is omitempty, so a zero value is dropped from the
JSON entirely rather than sent as 0, and strict clients (Infuse) abandon
playback on those items. On the production deployment 5,245 movies have
media_items.runtime = 0 while 5,239 of them have a correct probed
media_files.duration.

Resolve duration at read time the way /api/v1 already does: probed file
duration first, catalog runtime as the fallback. The item row is deliberately
not backfilled — one item can have several versions of different lengths, so
per-file data does not belong there.

- scanner: FirstDurationsByContentIDs / FirstDurationsByEpisodeIDs, batched
  lookups using the same "first live file with duration > 0, ordered by id"
  rule as the v1 API's contentDurationSeconds. The episode_id IS NULL guard on
  the content-id query is load-bearing: every episode file carries its series'
  content_id, so without it a series row would report an episode's duration.
- catalog: optional batchDurationFetcher extension on DetailService, following
  the existing extraFileFetcher pattern so test fakes need no changes.
  Nil-receiver safe and fail-soft — a failed lookup logs and degrades to the
  catalog runtime rather than failing the page.
- jellycompat: DurationSeconds on upstreamListItem/upstreamEpisode, a shared
  runtimeTicks resolver, and fillListItemDurations wired into the nine page
  producers. Fixes the three sites that had no fallback (itemFromList,
  episodeFromUpstream, HandleSearchHints); the detail and PlaybackInfo paths
  were already correct.

This is additive within the v1 rules — it populates a field that was
previously omitted. No field is renamed, removed, retyped, or repurposed.

* fix(jellycompat): avoid duplicate duration lookups

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 21:33:30 -04:00
c52ca7dd7a feat(admin): identify compat sessions and Android devices in the live session view (#495)
* feat(admin): identify Android devices by model in live session view

Android clients that send a bare default User-Agent (e.g.
"Dalvik/2.1.0 (Linux; U; Android 11; AFTKRT Build/RS8180.3729N)")
showed up as "Dalvik" in the admin live-session view, which tells an
operator nothing about the device.

Parse the model code out of the UA (the token between the last ';' and
"Build/") and map the Amazon Fire TV family and NVIDIA Shield to product
names. Unknown but parseable models fall back to "Android · <MODEL>"
instead of "Dalvik"; multi-word models like "Pixel 7" are preserved
whole. This is display-only: the session still stores the raw model code
in its user agent, and no response field or contract changes.

* feat(admin): mark Jellyfin-compat sessions with the JF pill by origin

The admin "JF" pill was derived at read time by substring-matching a
token list against the client name / user agent. A real Jellyfin
client that authenticates through the compat surface but sends a bare
User-Agent and no MediaBrowser client name (e.g. a Fire TV app) got no
pill, even though it plainly came through the Jellyfin API.

Stamp compat origin as immutable identity at session creation and carry
it through to the admin view:

- ClientInfo.IsCompat is set true in the jellycompat auth path; newSession
  copies it onto Session.IsJellyfinCompat.
- The flag rides the durable RecipeCard (next to the client metadata that
  already exists so the pill survives reconstruction) and is restored in
  ReconstructSession, so a server restart keeps the pill.
- buildLiveSessionSync -> worker.SessionSync -> a new compat_origin column
  on playback_sessions_sync (added migration); the reconciler upserts,
  reloads, and compares it so origin changes still publish and unchanged
  rows do not churn.
- The handler ORs the stored origin with the existing name/UA heuristic,
  which stays as a fallback for rows written before this column existed.

is_jellyfin_client keeps the same name and type on the wire; it is only
sourced more accurately.

* fix(admin): correct Android device labels

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 21:27:58 -04:00
54cfc48ce3 fix(metadata): improve localized series matching (#490)
* fix(metadata): improve localized series matching

* test(metadata): strengthen consensus regressions

* fix(metadata): harden localized matching

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 21:02:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4eab955ec1 build(deps): bump github.com/quic-go/webtransport-go (#508)
Bumps [github.com/quic-go/webtransport-go](https://github.com/quic-go/webtransport-go) from 0.10.0 to 0.11.1.
- [Release notes](https://github.com/quic-go/webtransport-go/releases)
- [Commits](https://github.com/quic-go/webtransport-go/compare/v0.10.0...v0.11.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/webtransport-go
  dependency-version: 0.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 18:14:25 -04:00
95edf19389 feat(invitations): shareable claim links and open-in-app on the claim page (#509)
* feat(invitations): always return the claim link so admins can share it directly

The claim URL was only surfaced when email sending failed. Admins who want
to hand the link over another channel (chat, SMS) had no way to get it —
and the raw token exists only in the send/resend response, since the server
stores just its hash.

The create and resend flows now always include claim_url (additive on
/api/v1), and the admin UI keeps the dialog open after either action with
the link and a labeled Copy button. Truncation and stacked buttons keep the
unbreakable URL from forcing horizontal scroll on phone widths.

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

* feat(web): offer to open invite claims in the Android app

The Android app already registers silo://invite?server=...&token=... with
a full native claim flow, but nothing ever emitted that link — an https
invite always ended in the browser.

On Android user agents the claim page now leads with a prominent 'Open in
the Silo app' button carrying that deep link, with the web form kept below
as the fallback ('or set up in the browser'). The button is a plain anchor:
a user-tapped custom-scheme link is the one reliable path, and we never
fire it automatically since there is no installed-check and a miss surfaces
an OS error. The password field's autofocus is suppressed alongside it so
the keyboard doesn't push the button off screen. iOS is excluded until the
Apple app registers the scheme.

The server origin travels in the server param verbatim, so non-443 ports
and plain-http LAN servers need no extra convention.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:01:23 -04:00
63e18cf37f fix(playback): prevent transcode resolution upscaling (#503)
* docs(playback): design transcode resolution clamp

* docs(playback): plan transcode resolution clamp

* fix(playback): prevent transcode resolution upscaling

* refactor(playback): share transcode resolution tiers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 13:07:05 -04:00
SuspenseandGitHub 5d6f6323d5 perf(web): smooth item sidebar collapse (#504) 2026-07-28 09:50:30 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
315f843fa5 build(deps): bump react-router from 7.15.1 to 8.3.0 in /web (#507)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.15.1 to 8.3.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@8.3.0/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 8.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:17:14 -04:00
271a2e1741 feat: emailed invitations, claim + household setup, and server-driven onboarding tour (#501)
* feat(invitations): add emailed pre-provisioned invitations

Admins can invite a specific person by email: the invitation pre-binds
role, access group, and library access, and the invitee only chooses a
password. Their email address becomes their username, so login gains an
email fallback (username lookup first, email column only on miss for
inputs that parse as a bare address).

- invitations table: single-use token (SHA-256 at rest) bound to one
  address; a partial unique index makes resend-supersedes atomic; no
  users row exists until accept, so a typo'd address can't squat a
  username. Status is derived from timestamps, not stored.
- internal/invitations: repository, service, and branded email through
  the shared internal/mail sender. When SMTP is off the claim URL is
  returned for manual delivery instead of failing.
- Admin endpoints /admin/invitations (list/create/resend/revoke) beside
  the existing invite-codes routes; public claim endpoints
  /invitations/{token} (+/accept) rate-limited with the other auth
  endpoints. Unknown/expired/revoked/used tokens are indistinguishable.
- Accept returns the same login response shape as signup, so clients
  reuse their session plumbing.

Spec: docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md
Plan: docs/superpowers/plans/2026-07-27-invitations-and-onboarding.md
Part of #215

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

* feat(web): add invitation admin tab, claim page, and household setup

- Admin → Users gains an Invitations tab: compose (email, role, access
  group, libraries, note, first-profile and tour toggles), list with
  derived status, resend, revoke. When the server has no SMTP the create
  response's claim URL is surfaced for copy-paste instead of a fake
  success.
- /invite/:token claim page: everything but the password was decided at
  send time, so it asks for exactly one thing and lands the user signed
  in. Expired/used links get an explanatory card, not a 404.
- /household-setup ("Who's watching?"): profile tiles plus the existing
  ProfileEditorDialog, all through the existing /profiles endpoint —
  no new backend. "Just me for now" is a first-class exit.

Part of #215

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

* feat(onboarding): add server-driven onboarding manifest and state

GET /onboarding/flow returns the ordered first-run tour for this server
and profile: steps for disabled features (requests, watch together,
recommendations, notifications) are filtered out server-side, surface=tv
drops steps needing text entry, and child profiles never see stops they
can't act on. Copy lives in Go, so a wording fix is a deploy — clients
render step kinds they know and skip unknown ones by contract.

setting_choice steps name an explicit write target (profile_field /
setting / device_setting) because playback quality is a profile column,
not a settings key — the tour writes through the same APIs the settings
screens use.

Per-profile completion state lives in the user store (SQLite schema v14
+ a Postgres twin table), keyed by (profile_id, tour_id) with monotonic
completed/skipped timestamps: finishing on one device silences every
other; a later progress write can never un-complete.

Part of #215

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

* feat(web): add the first-run feature tour

TourHost renders the server manifest as a modal overlay on Home: unknown
step kinds are skipped silently (the forward-compat contract), progress
posts per step, and setting_choice steps write real values through the
existing profile/settings mutations — by the last step the account is
genuinely configured. Skip is always one click and recorded server-side,
so no other device re-prompts. The tour ends by handing off to the
existing taste-seed picker, which now waits for the tour to finish
before its own redirect. Settings → Personalize gains a replay entry.

An invitation sent with show_tour=false plants a local hint that the
gate converts into a server-side skip for the first profile.

Part of #215

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

* fix(web): satisfy noUncheckedIndexedAccess in the tour's advance step

The Docker web build runs `tsc -b`, which applies the project's
noUncheckedIndexedAccess; the bounds check didn't narrow steps[next].
Look the step up once and branch on its presence instead.

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

* fix(web): blur the whole app behind the tour, sidebar included

The tour overlay rendered inside the app layout, where an ancestor
creates a fixed-position containing block — inset-0 pinned to the
content pane, leaving the sidebar completely un-scrimmed. Portal the
dialog to <body> so the scrim truly covers the viewport, and raise the
backdrop blur from sm (4px) to xl (24px) so card titles and nav labels
aren't legible through it.

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

* fix(onboarding): name features by their UI labels in the tour copy

"Same movie, different couches" never said what the feature is called.
Every feature card now leads with the name the sidebar actually uses —
Watch Party, Requests, Watchlist, Calendar, Notifications — and says
where to find it, so the tour teaches vocabulary, not just concepts.
Server-side copy, so all three clients pick this up with no release.

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

* feat(onboarding): add apps and Jellyfin-compat steps to the tour

Two new web-only feature cards near the end of the tour:

- "Take Silo with you" — native apps for iPhone/iPad/Apple TV and
  Android/Android TV, with outbound TestFlight and Play Store links.
  Steps gain an additive links field (label + url) that older clients
  ignore; the web TourHost renders them as external-link buttons.
- "Already use a Jellyfin app? It works here" — Infuse/VidHub/Findroid/
  Swiftfin connect via the Jellyfin API. Gated on
  jellyfin_compat.enabled (default-on, so unset counts as enabled;
  only an explicit "false" hides it).

Both steps are web-only: the apps card is pointless inside the apps it
advertises, and TV can't open store links. surface=phone/tv manifests
skip them, covered by tests.

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

* fix(web): keep the tour card responsive on phone widths

Verified every step at 1600px, 390px, and 320px with an automated
overflow check. Fixes it found:

- Link buttons (apps step) now wrap and truncate instead of extending
  past the card edge.
- The footer wraps at very narrow widths, so the handoff step's wide
  primary button drops to its own line rather than overflowing.
- Progress pips hide on phones — decorative, and they crowded the
  Back/Next buttons.
- The card scrolls within 85dvh so a tall step never pins its buttons
  off-screen on landscape phones.

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

* feat(web): render store links as branded badges in the tour

The apps step's plain outline buttons now render as store badges: the
Apple or Google Play mark with a store eyebrow (TestFlight beta /
Google Play) over the platform label — the familiar app-store badge
idiom. The brand is inferred from the link's host on the client, so
the server contract stays icon-free and non-store links keep the plain
external-link button. Labels drop the parenthesized store name the
eyebrow now carries.

Verified at 1600px and 390px with the overflow sweep: none.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:58:18 -04:00
54e184df85 feat(requests): enforce per-profile rating limits in discovery (#505)
* feat(requests): enforce per-profile rating limits in discovery

- Resolve each profile's max content rating and filter discovery, detail, and browse results against it, failing closed on missing ratings
- Reject request submissions for titles above the viewer's ceiling
- Add TMDB GetCertification backed by release_dates/content_ratings with a long-lived cache and singleflight
- Push certification.lte to TMDB for studio/network/genre browse as a cost pre-filter
- Backfill restricted section pages from a fixed window of TMDB pages to keep carousels populated and pagination stable

* fix(requests): address discovery rating review findings

- Preserve backfill overflow: sections use plain TMDB cursor semantics
  plus an additive next_page field instead of fixed windows, so an early
  stop never drops allowed titles from unconsumed pages (bit hardest at
  permissive R/TV-MA ceilings).
- Bound cold-path cost: DiscoverAll backfills at most 2 TMDB pages per
  section (vs 5 for a direct section request), capping worst-case cold
  certification hydration at 240 lookups instead of 600.
- Keep the TMDB prefilter a superset: rank-3 ceilings now push down
  certification.lte=NC-17/TV-MA rather than R, so titles the local
  ladder allows can't vanish upstream unrecoverably.
- Fail closed on foreign certifications: enforcement-path lookups use
  new US-only pickers (a Canadian PG no longer reads as US PG), while
  the display path keeps its any-country fallback. US multi-entry
  disagreements prefer the theatrical/real rating over festival NR.
- Detach shared certification fetches from the first caller's context
  (WithoutCancel + 30s bound) so one disconnecting client can't fail
  the singleflight result for concurrent waiters.
- Advertise enforcement via rating_restrictions_enforced on
  /requests/status so clients can feature-detect instead of
  version-sniffing.

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

* fix(requests): harden rating enforcement per second review pass

- GetDetail gates on the US-only enforcement certification (cached
  GetCertification) instead of the display rating, whose any-country
  fallback let a foreign "PG" pass the US ladder.
- pickUSMovieCertification takes the strictest recognized US rating when
  multiple release entries disagree ([PG, R] -> R); entry order is not
  meaningful and enforcement must not admit a title on its most lenient
  certificate.
- Certification singleflight uses DoChan so a canceled caller returns
  ctx.Err() immediately instead of blocking up to 30s on the detached
  shared fetch (which still completes for surviving waiters).
- Viewer rating ceiling resolves once per request and threads through
  discover/browse/detail enrichment (enrichPageWithCeiling); DiscoverAll
  drops from 12 scope resolutions per load to 1.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:34:30 -04:00
1a784a1cdf fix(player): restart backward HLS seeks (#499)
Preserve negative player-time offsets for targets before a copy-mode HLS window so the existing transcode restart path handles them.

Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
2026-07-27 16:23:45 -04:00
f235524365 feat(diagnostics): chunked report upload fallback for proxy body caps (#494)
* feat(diagnostics): chunked report upload fallback for proxy body caps

Diagnostics bundles can be up to max_bundle_bytes (10 MiB default), but a
reverse proxy in front of Silo commonly caps request bodies at nginx's
default client_max_body_size of 1 MiB. Such a proxy answers the single-shot
multipart upload with its own 413 before Silo ever sees the request, so any
report over the cap could never be delivered.

Add a chunked upload fallback under /api/v1/diagnostics/reports/uploads:

- POST   /                      {manifest, bundle_bytes} opens a session
- PUT    /{id}/chunks/{index}   streams one ≤768 KiB chunk (proxy-safe)
- POST   /{id}/complete         ingests the assembled bundle
- DELETE /{id}                  best-effort abandon

The assembled bundle goes through the exact same Ingest path as the
single-shot endpoint, so every content check (manifest contract, archive
sha/bytes/entries, quotas, profile attribution) applies identically.
Sessions reuse internal/uploads (the plugin chunked-upload spool manager)
plus a small owner map for per-user isolation; they spool to disk, expire
after 15 minutes, cap at one per user / 16 global, and complete shares the
existing per-user + global in-flight ingest limiter.

/diagnostics/status now advertises upload_chunk_bytes so clients can detect
support; older servers omit the field and clients treat that as
unsupported. The demo guard's diagnostics prefix gains PUT to cover the
chunk route.

Verified end to end against an OpenResty proxy with a 1m body cap: the
single-shot upload 413s, the same 1.6 MiB bundle uploads in three chunks
and lands as an accepted report; also exercised from the tvOS client's
fallback path in the simulator.

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

* fix(diagnostics): harden chunked upload sessions per review

- Reserve the per-user slot and global cap atomically in init (a
  reservation map counted with live sessions), so concurrent inits by one
  account can no longer fan out past one session or transiently exceed the
  cap. Creation failures roll the reservation back.
- Move chunk body I/O outside the uploads.Manager mutex: a slow client
  streaming one chunk no longer serializes every other session's chunk
  writes, completes, and cancels. A per-chunk in-flight flag rejects
  duplicate concurrent writes to the same offset (ErrChunkBusy → 409), and
  cancel/expiry defer spool-directory removal to the last finishing
  writer.
- Chunk arrivals refresh the session expiry, making the TTL an idle
  timeout instead of an absolute deadline so a slow-but-progressing upload
  cannot expire mid-transfer.
- Extend the request read deadline on chunk PUTs and both deadlines on
  complete, matching the single-shot handler's slow-uplink handling.
- Keep the session when complete's availability re-check fails
  transiently (status load error → 500): only definitive
  disabled/storage-unavailable answers discard the spool, so a retried
  complete succeeds without re-uploading every chunk.
- Reclaim orphaned spool directories at startup (a restart previously
  stranded the old process's partial uploads forever) and sweep expired
  sessions on a timer instead of only from later init traffic.
- Document that session state is process-local and what that means for
  multi-replica deployments.

Adds concurrency/race tests (go test -race) for atomic admission,
same-chunk write exclusion, expiry refresh, transient-status retry, and
startup reclaim.

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

* fix(diagnostics): count detached chunk writers and lift chunk PUT write deadline

Second review round:

- A canceled session whose slow chunk writer was still draining held a
  connection and spool disk but vanished from every count, so a
  cancel-and-reinit loop could stack unbounded live writers behind the
  16-session cap. The uploads manager now parks such sessions in a
  detached set (exposed as DetachedWriterSessions) until their last
  writer returns, and diagnostics init counts them in its admission gate.
- Chunk PUTs now extend the write deadline as well as the read deadline:
  on an uplink slow enough to eat the server's 120s WriteTimeout, the
  stored chunk's JSON acknowledgement would otherwise be lost and the
  client would retry an already-accepted chunk.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:07:37 -04:00
8dea4b9056 fix(playback): stop a broken Dolby Vision RPU from hanging playback (#485)
* fix(playback): stop a broken Dolby Vision RPU from hanging playback

A Profile 7 source whose RPU ffmpeg cannot parse took the whole session
down. The dovi_rpu bitstream filter does not fail cleanly — it rejects
every packet while ffmpeg runs on, so one observed session emitted
376,316 stderr lines before the process was killed, no manifest was ever
built, and the client got a 503 after ~10 seconds that it showed as an
endless spinner:

  [dovi_rpu] Failed to read unit 1 (type 39).
  [vost#0:0/copy] Error applying bitstream filters to a packet:
      Invalid data found ... Invalid SEI message: payload_size too large

Whether the strip works is a property of the file, not of ffmpeg, so
SupportsDoviRPUFilter cannot answer it — but asking ffmpeg to strip two
seconds to the null muxer can, in about a second. Profile 7 sources are
probed once each on the start path and the result is cached; a source
that fails is copied without the filter, which leaves the base layer and
plays. Refusing to play at all does not.

The probe reads stderr rather than trusting the exit code: ffmpeg treats
a per-packet bitstream-filter error as non-fatal and exits 0, which is
exactly how a stream that could never start reached a live session.

Cache is keyed on path plus size so a replaced file is re-probed, and
bounded like the letterbox cache. A nil probe keeps stripping, since most
Profile 7 sources need it.

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

* chore(playback): use InfoContext in the rpu probe

* fix(playback): decide the DV RPU strip in the plan, not at the transport

The probe answered the right question in the wrong place. Suppressing the
bitstream filter as the transport was being built left the plan still
promising DynamicRange "hdr10", Claims.Video.HDR10 and the
dolby_vision_metadata_removed / hdr10_base_layer_preserved validated
claims, and left session.RemuxDVMode persisted as strip_to_hdr10 — so the
client was told it was getting clean HDR10 while receiving Profile 7 with
dangling RPUs, and every path that re-derives the filter from the durable
session put the hanging filter straight back:

  - HandleStartTranscode (quality change, seek, burn-in restart), which
    also re-derives it from file.PrimaryDVProfile() == 7 with no plan at all
  - the remote audio-switch restart, from updatedSession.RemuxDVMode
  - the progressive-remux transport, which plan_v3 evaluates *before* the
    HLS branches, so for many clients the broken file never reached the
    probe in the first place

The verdict is now a planner input alongside the transformation
registries: the registries answer whether the executor carries the
transformation, this answers whether the file does. A source that fails it
is never planned onto a strip route, so the plan's claims, RemuxDVMode and
every restart derived from them agree with what the pipeline can produce.
With no tone-map recipe in this tree an HDR10-only client has no route
left, so it gets a dv_conversion_unsupported terminal naming the real
cause rather than a generic HDR message; a client that can run its own DV
transformation still gets that route, with a degradation warning
explaining why the server route was dropped.

The two paths that bypass the plan entirely are gated at the executor:
legacy/auto remux neutralizes the profile exactly as it already does for a
missing dovi_rpu filter, and the explicit v3 strip recipe fails loudly
rather than emit dangling RPUs under an HDR10 claim.

The probe itself:

  - Tri-state verdict. Only a stderr-confirmed rejection is cached. A
    timeout, a cancelled request or an ffmpeg that will not start is
    inconclusive: the strip is kept and nothing is written, so one client
    disconnecting can no longer disable the strip for a file permanently.
  - Singleflight, so concurrent or retried starts of a title share one run.
  - Timeout cut to 6s, inside the budget a client waits on the manifest,
    and off the session lifecycle lock now that it runs at planning time.
  - Keyed on size and mtime as well as path and binary, so a file replaced
    in place with the same length is re-probed.
  - stderr capture bounded at 64 KiB; the markers are in the first lines
    and a rejecting filter emits a pair per frame.
  - The head-only coverage is stated rather than asserted: this catches a
    source that rejects from the first access unit, not one that breaks an
    hour in.

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

* fix(playback): keep the RPU probe alive when its caller leaves

The probe rode the caller's context, so a leader whose client disconnected
returned dvRPUUnknown — and CanStrip then handed that fail-open to every
follower already blocked on the shared call, even though their own requests
were still alive. One client walking away was enough to give a live session
the strip the source cannot survive, which is the hang this whole change
exists to prevent. The work was also thrown away, so the next start paid for
the probe again.

Run it under context.WithoutCancel instead. dvRPUProbeTimeout still bounds
it, so nothing is left running; a verdict reached after the leader has gone
is still correct and still worth caching. Follower behaviour is unchanged:
a follower whose own request is cancelled still leaves immediately rather
than waiting on someone else's probe.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-27 08:15:25 -04:00
baa33768d6 fix(scanner): stop reporting an unusable ffprobe as an empty folder (#468)
* fix(scanner): stop reporting an unusable ffprobe as an empty folder

parseAudiobookFolder and parsePodcastShow signalled "this folder holds no
audio files" by wrapping os.ErrNotExist, and their reconcile callers skipped
on that. exec also wraps fs.ErrNotExist when the configured ffprobe binary
cannot be run, so a wrong playback.ffmpeg_path made every candidate folder
look empty: the scan logged processed=N failed=0, indexed nothing, and gave
the operator no clue why the library stayed empty.

Introduce an errFolderHasNoMedia sentinel that deliberately does not wrap
os.ErrNotExist, and skip on that instead. A folder that disappears between
the scan walk and the parse still maps to the sentinel, so a mid-scan rename
or delete stays a quiet skip rather than a scan failure.

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

* fix(scanner): bound the all-failed scan summary instead of joining every failure

The sentinel change in this PR makes a previously-unreachable path reachable.
Before it, a misconfigured ffprobe made every candidate folder look empty, so
the scan skipped everything and `failed` stayed 0 — the `failedCount ==
processedCount` branch never fired. Now that an unusable ffprobe propagates as
a real failure, that branch is the expected outcome of a first scan with a bad
`playback.ffmpeg_path`, and it joins one wrapped error per failed folder.

On the 240k-folder library the scan code is written for, `errors.Join` over
that slice produces a ~64 MB error string (measured) that is written verbatim
into `scan_runs.error_message` and republished over the Redis events channel
and the admin SSE stream. The `failures` slice itself also grew unbounded for
the whole scan even when the all-failed guard could not fire (any rescan with
`skipped > 0`), holding hundreds of megabytes across a multi-hour scan before
discarding it.

Add a `scanFailures` collector that retains the first 20 failures and counts
the rest, joining them with a trailing "and N more failures (elided)". The
retained sample still names the cause, which is the entire purpose of the
summary. The same 64 MB case now produces 5.4 KB.

The ebook and manga scans have the identical shape and the same exposure via
their own probe failures, so all four call sites share the collector rather
than fixing the two audio paths alone. `failMu` now guards only `cancelErr`
and is renamed `cancelMu` to match.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-26 23:35:36 -04:00
172beb99ef fix(watch-together): stop a dropped socket reading as the host leaving (#487)
* feat(watch-together): make vote rooms actually vote

selection_mode has been stored, normalized and published since the
feature landed, and nothing has ever read it. A "vote" room behaved
exactly like a host_pick one: members could suggest and vote, the tally
was recorded and broadcast, and then the host promoted whatever they
liked regardless of it.

In a vote room the host now starts the winner rather than choosing it.
Promoting anything other than the leading suggestion is refused, because
being able to overrule the tally makes the mode host_pick with extra
steps and turns the vote counts on everyone else's screen into
decoration.

The winner is the head of the repository's existing ordering
(vote_count DESC, created_at ASC): most votes, ties to whoever suggested
first — deterministic, and re-suggesting a title cannot jump the queue.

A room where nobody has voted has no winner and says so, rather than
quietly promoting the oldest suggestion as though a vote had happened.

host_pick rooms are untouched: the host still promotes freely.

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

* fix(watch-together): close the second door into a vote room's selection

Gating PromoteSuggestion left SelectItem wide open: it is host-only but
was not gated by selection mode, so the host of a vote room could set any
title directly and bypass the vote entirely. Enforcing the tally on one
path and not the other makes the vote counts on everyone else's screen
decoration.

A vote room now refuses a direct selection outright. The winner is the
only way in.

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

* fix(watch-together): stop a dropped socket reading as the host leaving

hostDisconnectTTL was 15 seconds, which treated any transient drop as a
departure. An explicit leave and an explicit close already tear the room
down immediately, so this timer only ever covers a host who has NOT said
they are going — and at 15s a host who backgrounded the app, moved
between screens, or hit a brief network blip lost the room for everyone
with a "host_left" nobody could explain.

Two minutes survives a reconnect or an app switch, and is short enough
that a genuinely departed host does not leave a room open all evening.
The janitor still reaps idle rooms independently.

This matters for what the clients are growing into: a room you stay in
while you browse for something to suggest. A client that drops its socket
when the lobby leaves composition should cost you a reconnect, not the
room.

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

* fix(watch-together): let a vote room actually start its winner

The vote gate landed on both doors into a room's selection, but promoting
the winner walks through SelectItem to commit — so the gate meant to stop
the host bypassing the vote also stopped the vote itself. Vote rooms could
not start playback by any route.

Split the commit path: SelectItem keeps the gate for direct requests, and
PromoteSuggestion goes through the internal path once it has confirmed the
suggestion is the winner. Map ErrVoteRoomSelection in the promote handler
too, so a future regression there reads as a conflict rather than a 500.

Add service-level tests for both gates — the previous tests only covered
the pure winnerFrom helper, which is why the suite stayed green while vote
rooms were non-functional.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-26 22:01:31 -04:00
148c9291c5 feat(web): add Connect Apps settings page for compat sign-in (#488)
* feat(web): add Connect Apps settings page for compat sign-in

Jellyfin-protocol clients offer one username box and one password box and
never prompt for a profile, so signing in requires `account#Profile` and
`password#PIN`. Nothing in the product taught that syntax, and every way of
getting it wrong surfaces as the same "invalid username or password", so it
became a recurring support burden.

Add Settings -> Connect Apps, which states the credentials for the signed-in
account rather than describing them in the abstract. The page is segmented by
app type: the two formats are never shown at once, each side names the apps it
covers, and the compat side is visually distinct so it cannot be mistaken for
the normal Silo login. The compat listener's separate address is shown too,
since pointing a client at the Silo address fails identically to a bad
password.

Backend adds GET /api/v1/compat/connect-info, an account-scoped read of the
compat listener's enabled flag, public URL, and server name. The admin status
endpoint already covers this ground for operators, but it also reports install
paths and version provenance, so it stays admin-only; this returns only what a
client learns by connecting anyway. It is auth-only and deliberately not
profile-scoped, since it describes how to sign in.

ConnectInfoForConfig shares the enabled-flag precedence with
WebComponentStatusForConfig via compatEnabled, so the two endpoints cannot
disagree about whether compat is on.

The page declines to display a username it knows cannot work: profile names
permit `#` but the resolver splits at the last one, so `alice#Movie #2` parses
as account "alice#Movie". Such profiles get an explanation and no copy button
instead of a string that fails to authenticate.

Part of #432

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

* fix(web): harden Connect Apps against misleading sign-in states

Review of the first pass found six ways the page could state something
untrue. Each one matters more than usual here, because the page exists
specifically to stop people guessing at credentials.

Report the running listener, not the stored setting. jellyfin_compat.enabled
is restart-required and cmd/silo builds the compat server from the boot
config alone, so the stored value describes intent. Reporting it promised
credentials for a listener that does not exist yet, or claimed the API was
off while the running one kept serving. ConnectInfo now returns the boot-time
state plus a pending_restart flag, and the page distinguishes "not running
yet" from "turned off". The admin status endpoint keeps reporting configured
intent, so the two intentionally diverge until a restart.

Stop presenting fetch failures as a disabled compat API. React Query clears
isLoading on error, so a failed request rendered "the compatibility API is
turned off" and sent users to an admin about a setting that was fine. A
failed profile list was worse: it fell through to an empty list and offered
the bare account name, which silently drops the profile suffix. Both now
withhold credentials and say the load failed.

Detect accounts that cannot use password login. Compat login is hardwired to
the local provider, which rejects accounts with local_password_login_enabled
false before checking any password, so SSO and plugin-provisioned accounts
can never authenticate. The page told them to type a password anyway; it now
says the compat API cannot accept the account.

Flag loopback compat addresses. jellyfin_compat.public_url defaults to
http://127.0.0.1:8096, which resolves to the client device on the phones and
TVs this page names. An untouched default was offered as the exact address to
copy; it is now explained instead of presented as usable.

Apply the #-in-profile-name guard to the summary list too. The selected-profile
field withheld an unusable username while "Every profile at a glance"
reintroduced it two sections below.

Read only the three settings this endpoint consumes. GetAll on the encrypted
repository decrypted every stored secret on each authenticated page view, and
an unrelated decryption failure would have silently dropped valid compat
overrides.

Invalidate the connect-info cache when an admin saves jellyfin_compat.*
settings. The address applies without a restart, but the page cached it for
five minutes and kept offering the old value for copying.

Part of #432

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:07:48 -04:00
99d205676f fix(metadata): prevent stale cross-provider IDs (#480)
* fix(metadata): prevent stale cross-provider IDs

* fix(metadata): address stale ID review findings

* fix(migrations): build the stale-ID primary key concurrently

ALTER TABLE ... ADD PRIMARY KEY builds the index under ACCESS EXCLUSIVE,
blocking reads and writes on stale_media_ids for the whole build. Create the
wider unique index with CREATE UNIQUE INDEX CONCURRENTLY and attach it with
ADD CONSTRAINT ... PRIMARY KEY USING INDEX instead; all three key columns are
already NOT NULL, so the attach is metadata-only. Same treatment on the
rollback path, plus the repo's INVALID-remnant cleanup so a failed concurrent
build is not silently accepted by IF NOT EXISTS.

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

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:19:33 -04:00
02203d9e40 fix(playback): trust the server's media runtime end to end (#482)
* fix(scanner): reject durations that imply an impossible bitrate

The duration-plausibility rule only rejected videos of 10 seconds or less,
so a feature film that probed as 61 seconds passed untouched and persisted.
Clients then had nothing trustworthy to anchor on: Android's grow-only
duration ratchet has no floor to hold when the catalog value is wrong, so
the playback engine's growing-HLS-window duration won and a 90-minute movie
displayed as ~1 minute.

Size and duration together pin an implied bitrate, which separates the two
cases the absolute floor conflates. A genuine short clip has an ordinary
bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling
sits far above any real medium, so legitimate content cannot trip it — and
unlike the absolute floor, it does not false-positive on a genuine
high-bitrate short.

Also bump the repair-rule revision marker so rows judged by the previous,
weaker rule are re-checked once under this one. Without that bump an
improved rule never reaches the rows it was written for.

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

* fix(playback): publish source runtime in v3 plans and stop faking the copy seek window

Two defects with one root: a v3 plan described where playback sits without
ever stating how long the media is.

Add source.duration_seconds. It is the file's full runtime, never
`total - source_start` and never adjusted by timeline_offset_seconds, and it
is omitted rather than null when unknown — clients that coerce null to a
numeric default would read it as zero, the exact value this field exists to
stop them inventing. It is set in SourceDescriptorFromFileV3, the single
place every delivery already flows through, so direct play, progressive
remux, HLS remux and HLS transcode all carry it.

Until now the v3 plan omitted duration entirely, so clients fell back to the
playback engine. On an HLS copy remux the server intentionally serves
FFmpeg's still-growing playlist, so the engine reports the length produced
so far. With no server-supplied runtime to anchor on, a feature film played
back as a couple of minutes. The legacy protocol already answered this
correctly via fileDurationSeconds; this restores parity.

Separately, the copy branch published seek_window_end_seconds as the media
runtime. That made the window look *complete*, which clients read as proof
that any target inside it is locally seekable, so they native-seek past the
produced head of a growing playlist instead of asking for a reanchor. Leave
the end open: an incomplete window plus can_seek_anywhere=false routes every
seek through the server, which is what legacy did before v3 added the bound.

Advertise plan_source_duration_v1 so a client can distinguish "this server
does not populate the field" from "this server knows the runtime is
genuinely unknown" — without it, both look like an absent field and a client
cannot tell whether its own catalog fallback is still required.

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

* fix(web): pair the exit position with the media runtime, not the element duration

The player's exit state converts its position to media time but took the
duration from the video element, which is player-local. On a remux or
transcode stream the element only covers the window produced so far, so the
two values live in different coordinate systems.

Resuming a movie 50 minutes in makes that concrete: the exit position is
~3060s of media time while the element reports ~120s. The progress cache
then evaluates `position >= duration`, marks the item completed, latches the
watched badge, and — because completion clears the resume point — resets
position to 0. Exiting a resumed movie destroyed the resume point and
claimed it had been watched.

The server's runtime is authoritative and already expressed in media time,
so prefer it and fall back to the element only when no server value exists.
The rule moves into mediaTimeline.ts next to the coordinate conversions it
depends on, which is also what makes it testable — VideoPlayer itself has no
test harness.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:12:29 -04:00
QuickandGitHub 10394b0a05 fix(scanner): stop hiding media when a library root is offline (#472)
* fix(scanner): stop hiding media when a library root is offline

A scan that cannot read a library root found no files there, so every
cataloged file under it was marked missing. Catalog reads all filter on
missing_since IS NULL, so marking is equivalent to deletion from a user's
point of view: the title leaves browse, search and next-up, and playback
answers "Source media file is missing" for media that is intact on disk.

The dead-root protection already existed but only guarded the destructive
operations. protectedConfiguredRoots was computed *after* the marking loop
in scanPaths, and applyScopedScan received the protected set but applied it
only to its force-delete branch. So an unreachable root could not lose its
rows, but could still have its entire catalog hidden until the next
successful scan.

On a CephFS deployment whose per-library subvolume mounts flap, this marked
190 present files missing in a single day — 15% of all missing-flagged rows
were files sitting untouched on disk, some flagged more than 20 hours after
their last write.

Hoist the probe above the marking loop and skip files under an unreachable
or suspect-empty root in both the folder and scoped paths. Pass the
unreachable set to the walked-scope call too: a nested child mount can die
under a healthy parent, and its rows are inside the parent's scope.

An offline root tells us nothing about whether its files exist. The only
safe reading is to leave them alone and let the next good scan decide.

Genuine deletions under a reachable root are unaffected and still marked
and swept on the same schedule as before.

Report the count as ScanResult.MissingSkippedProtected and log it, so an
operator can tell "my library shrank" from "my mount dropped".

Known gap: a suspect-empty *nested child* root under a healthy parent is
still marked missing, because the suspect set is not resolved until after
the walk loop. Unreachable roots — the case observed in production — are
covered.

* fix(scanner): protect suspect-empty and partially-walked roots too

Addresses review findings on #472. The original change guarded missing-marking
against probe-unreachable roots, but left three ways for a storage fault to
still hide a healthy library.

Suspect-empty detection was reactive. suspectEmptyRoots asked
ListRootsWithOnlyMissingFiles, which returns a root only once it has NO live
rows left. On the first scan after a mount drops — the moment that matters —
the rows are still live, so the root was not classified suspect and the scan
marked everything missing. The protection then engaged on the next scan, in
time to protect the wreckage. Ask ListRootsWithCatalogedFiles instead: any
cataloged row under an empty-but-reachable root is the lost-mount signature.
Intentional emptying is still reachable through the operator's one-time
cleanup allowance, which is the deliberate path for it.

Nested suspect-empty children were unprotected. Root compaction sends only the
populated parent through the walked-scope branch, which received only
unreachableRoots, so an empty child mountpoint had its rows marked missing on
its parent scanning cleanly. Pass the suspect set as well.

Partial walks were treated as authoritative. walkLogicalTree deliberately
swallows per-entry Lstat/ReadDir failures so one bad file cannot abort a scan
of a million, and collectLogicalFilePaths passed nil for the failure counter —
so the video path had no signal at all. A mount dying partway through
traversal produced a short file list indistinguishable from a large deletion.
Thread the counter through, and exclude a scope whose walk came back
incomplete from missing reconciliation, mirroring what the ebook scanner
already does via ebookRootScan.failed.

Also extract the duplicated mark-missing loop into markMissingExcludingProtected
so the folder and scoped paths cannot drift, and correct two comments that
still described the pre-fix "files are marked missing" behaviour — the exact
text a future reader would have trusted when reintroducing this bug.

TestScanFolderNestedSuspectEmptyChildRootProtection asserted the old
behaviour and is updated accordingly.

* fix(scanner): scope walk-failure protection and stop pruning on partial walks

Addresses the second Codex review round on #472. The previous commit's
incomplete-walk protection was too blunt in one direction and applied too late
in another.

Walk failures were counted, not located, and any non-zero count protected the
whole library root. A dangling symlink is both common and permanent, so that
would have suppressed missing-file reconciliation for its entire root on every
future scan — genuinely deleted titles would stay live indefinitely. That is
the same class of bug as the one this PR fixes, pointing the other way.
recordWalkFailure now records the logical path of each unreadable entry, and
only those paths are protected. Per-entry failures record the child path, so a
dangling symlink protects itself and nothing else, while a directory that
cannot be read protects its subtree.

Snapshot and group pruning ran before the protection. reconcileScannedRoots
and reconcileScannedGroups delete whatever the walk did not see, and both run
ahead of the missing-file guard, so a partial walk still dropped root
snapshots, observed locations and group locations for the unread portion —
corrupting later metadata matching even though the media_files rows survived.
Upserting what was seen is always safe; pruning now waits for a scan that read
the whole tree.

The confirmed-cleanup allowance was consumed to no effect for nested suspect
children. The walked-parent branch protected them unconditionally and runs
before the allowance is consumed, and an already-reconciled scope cannot be
revisited — so arming the allowance burned the confirmation while the child's
rows stayed live forever. Read the allowance without consuming it before the
walk loop, and honour it there. Unreachable roots stay protected either way:
an outage is never a confirmation to erase a catalog.

Two new regression tests, plus signature updates in the ebook pipeline, which
already tracked walk failures and now shares the path-based representation.

* fix(scanner): re-probe nested roots and gate group pruning on walk completeness

Third Codex review round on #472; both findings confirmed.

Group pruning ignored walk completeness in the subtree path. scanPaths passed
the completeness decision to reconcileScannedRoots but left
reconcileScannedGroups on !allowEmptyRootGuard, which is always true for
ScanSubtree — so a subtree scan that hit an unreadable directory still replaced
group snapshots and locations from a partial inventory. Same rule now applies
to both.

Nested roots were not re-probed before their parent was reconciled. Root
compaction folds a child mount into its parent for traversal, so a child that
is healthy at the initial probe but drops before the parent is walked leaves no
scope of its own, and the post-walk re-probe only revisits scopes that walked
empty. The parent walks files, looks healthy, and the child's rows are marked
missing on its success. reprobeNestedRoots re-checks this root's configured
children immediately before reconciling, protecting any that have since become
unreachable — or suspect-empty, unless the operator has confirmed cleanup.

Also guard suspectEmptyRoots against a nil file repository, matching
emptyCleanupArmed: without a catalog there is nothing to protect.

* fix(scanner): keep re-probed outages protected through folder-wide cleanup

Fourth Codex review round on #472; both findings confirmed. The first could
destroy data.

reprobeNestedRoots protected a root it found offline only for the scope being
reconciled, then discarded the result. The folder-wide membership reconcile and
the trash sweep afterwards rebuilt their protected set from the initial probe
alone, so rows under a child that dropped mid-scan — already marked missing and
past the removal grace — were hard-deleted by the very scan that noticed the
outage. Accumulate those roots in reprobedRoots, fold them into
protectedScanRoots, and reuse that set for the membership reconcile and sweep
instead of rebuilding. They now also land in ScanResult.UnreachableRoots so the
folder warning reflects the outage rather than presenting a partial scan as
clean.

Snapshot and group pruning was enabled for scopes that were never walked. The
gate was len(walkFailures) == 0, but an unreachable root gets nil walkRoots, so
it has no walk and therefore no failures — and pruning then deleted its
snapshots, observed locations and group locations even though its media rows
were protected. The same held for a suspect-empty child compacted into a
populated parent. Pruning now additionally requires that the scope was actually
walked and contains no protected path.

The new test pins that the sweep honours the protected set it is given. It does
not reproduce the mid-scan race itself: staging that needs the drop to land
between the probe and the walk, which a test cannot reach without hooks. That
path is covered by inspection, and the test comment says so rather than
implying coverage it does not have.

* fix(scanner): route every protection source through one folder-wide set

Fifth Codex review round on #472. Two P1s, one of them the second data-loss
path in this area — and the direct sibling of the one fixed in 35326adc, which
is the reason this commit changes the structure rather than patching another
edge.

Rows beneath a directory the walk could not read were protected only inside
applyScopedScan. The folder-wide protected set was rebuilt from the probe
results alone, so DeleteMissingByFolder could permanently delete rows past the
removal grace under a subtree this scan never managed to read — deleting on the
strength of an observation that was never made.

The recurring defect is structural: protection is discovered in several places
(initial probe, mid-loop re-probe, per-scope walk failures) and consumed in
several more (scoped reconcile, membership reconcile, trash sweep), and each
fix so far has wired up one edge and missed another. Every source now
accumulates folder-wide and every consumer reads the combined set, so a new
source has one place to register instead of several to remember.

reprobeNestedRoots classified from two probe batches. It called
probeUnreachableRoots, then suspectEmptyRoots probed the same paths again; a
child dropping between the samples was reachable to the first and discarded by
the second, which only returns reachable-and-empty roots. It now classifies
both states from one batch, so the disconnect it exists to catch cannot fall
between its own probes.

Re-probed roots kept their classification instead of being collapsed into
unreachableRoots, which had been reporting a suspect-empty child as
unreachable and giving operators contradictory failure information.

The new regression test is verified to fail with the propagation disabled and
pass with it, rather than assumed to cover the path.

Not addressed: the cleanup allowance is read without being reserved, so two
overlapping full scans of one folder can both observe it armed. Narrow, needs
a transactional reserve in the scan-claim query, and is left for follow-up
rather than bundled here.

* fix(scanner): resolve root protection before scoped metadata pruning

Sixth Codex review round on #472.

scanPaths pruned before it knew what was protected. reconcileScannedRoots and
reconcileScannedGroups ran roughly 160 lines ahead of protectedConfiguredRoots,
so a ScanSubtree of a mount that dropped but left a reachable empty mountpoint
walked clean, reported no failures, and pruned root snapshots and observed and
group locations against that empty inventory — preserving the media rows while
deleting the metadata describing them. Protection is now resolved before any
reconciliation, and both prunes share one decision, matching applyScopedScan.

Pending empty scopes never re-probed their nested children. A parent whose only
media lives in a child walks empty when that child drops, so it lands in
pendingEmptyScopes rather than the populated-scope branch where
reprobeNestedRoots ran. Probing the parent alone proves nothing: it still holds
the child's bare mountpoint directory, so it reads present and non-empty. With
a healthy sibling keeping the folder-wide empty guard quiet, nothing protected
the child. Both branches now re-probe.

MissingSkippedProtected never left the scanner. Both ingest-to-result
conversions copied every other cleanup count but not this one, and
events.ScanRunResult had no field, so scan history, completion events and API
responses reported an all-zero no-op for a scan that skipped files because
storage was offline. Added as a new field, which is additive under the v1 API
rules.

Test honesty: the new test does NOT exercise the pending-scope re-probe. It
empties the child before the scan, so the initial probe classifies it and
protection arrives by that path — verified by confirming the test still passes
with the re-probe disabled. It is named and commented for what it does cover.
The mid-scan race behind both re-probe fixes needs the drop to land between the
probe and the walk, which is not reachable from a test without hooks; those
fixes rest on inspection.
2026-07-25 14:47:12 -04:00
QuickandClaude Opus 5 482efdf140 chore: add T3 Code project icon
T3 Code resolves a repository icon from t3.json's iconPath, falling back to
well-known locations such as assets/icon.png. Neither resolved in this repo,
so it showed the default folder icon in the T3 Code sidebar.

The icon reuses the canonical Silo mark unchanged over a navy backdrop,
so the Silo repos stay distinguishable at the ~14px the sidebar renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 18:37:51 +00:00
QuickandClaude Opus 5 9e7fe79590 docs: record live TV, IPTV, and .strm as permanent non-goals
Live TV, OTA/DVB tuners, IPTV, EPG/XMLTV guide sync, DVR, and .strm
remote-URL shortcuts are permanently out of scope for Silo. The
deciding factor is app store distribution: the first-party iOS, tvOS,
macOS, and Android clients ship through Apple and Google, and a server
that plays arbitrary remote stream URLs puts the entire client suite at
risk of rejection or takedown, not just the feature. Secondarily, live
TV is a separate product surface whose reliability burden competes with
the core playback path.

This was an undocumented boundary until now, and contributors spent real
effort against it (#419, #420, #474). Write it down in docs/non-goals.md
and summarize it in AGENTS.md so both humans and agents see it before
proposing or implementing in this area.

Refs #474, #419, #295

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 15:07:42 +00:00
355508f6e7 fix(requests): retire stalled targets when presence confirms the media (#470)
reconcileRequest completes a presence-confirmed request only when it has no live targets, so a quality-agnostic TMDB hit cannot orphan an in-flight download. That gate assumes the router eventually moves every target to a terminal state; when it does not, the request is pinned open forever even though the media is in the library.

Retire targets stuck in queued past a 24h horizon with no status transition when presence confirms the media, and let the existing target aggregate drive request status. Targets actively downloading are never retired. Each retirement logs at WARN, since reaching this path means a router is misbehaving.

Part of #469

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 02:54:30 -04:00
660 changed files with 93738 additions and 7236 deletions
+177
View File
@@ -0,0 +1,177 @@
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
# 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
+1
View File
@@ -81,3 +81,4 @@ docker-compose.override.yml
docker-compose.local.yml
.playwright-cli/
output/
/settingsgen
+19 -14
View File
@@ -59,19 +59,24 @@ linters:
misspell:
locale: US
issues:
exclude-dirs:
- web
- migrations
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/
exclude-rules:
# Allow repeated strings in test files
- path: _test\.go
linters:
- goconst
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
+30 -2
View File
@@ -17,6 +17,17 @@ Put new code in the package that owns the behavior rather than in a catch-all he
extracting shared logic over duplicating it, and prefer changing existing code over bolting a
local workaround onto it.
## Non-goals
Most of this codebase's scope is open; a short list is permanently closed. Read
[docs/non-goals.md](docs/non-goals.md) before proposing or implementing in those areas.
**Live TV, OTA/DVB tuners, IPTV, EPG/XMLTV, DVR, and `.strm` remote-URL shortcuts will not be
accepted** — not in core, not as a plugin, not in a client. The first-party clients ship on the
Apple and Google stores, and a server that plays arbitrary remote stream URLs puts the whole
client suite at risk. This is settled product direction, not a design problem to solve; do not
write code for it, and say so plainly if asked.
## Gotchas
**Migrations.** New DB changes are Goose SQL migrations in `migrations/sql/`, created with
@@ -60,18 +71,30 @@ SDK, in the catalog, or in a specific plugin repo.
## Building and verifying
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `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 test`, `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
@@ -95,6 +118,11 @@ 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.
+4
View File
@@ -32,6 +32,10 @@ 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,6 +29,9 @@ 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`.
+109 -1
View File
@@ -1,4 +1,4 @@
.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
.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
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,6 +54,97 @@ 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/audiobooks/player/useAudiobookPlayback.test.ts \
--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
# Check committed content for local machine path leaks.
verify-local-paths:
scripts/check-local-path-leaks.sh
@@ -71,6 +162,23 @@ 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
+4
View File
@@ -212,6 +212,10 @@ If you prefer running Silo without Docker:
## Reporting Issues
Client implementers can use the [Canonical Settings API guide](docs/settings-api.md)
for contract discovery, contextual headers, remote scopes, effective reads, and
the admin projection.
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
Please include:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+628
View File
@@ -0,0 +1,628 @@
// 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
@@ -0,0 +1,90 @@
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)
}
}
+177 -12
View File
@@ -19,6 +19,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -95,6 +96,7 @@ 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/subtitles"
"github.com/Silo-Server/silo-server/internal/taskmanager"
taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository"
@@ -307,6 +309,16 @@ 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 {
@@ -391,10 +403,30 @@ 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 {
@@ -444,6 +476,21 @@ 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")
@@ -847,8 +894,10 @@ func main() {
)
}
var watchProviderService *watchsync.Service
var watchProviderRegistry *watchsync.Registry
var watchProviderRepo *watchsync.PostgresRepository
if deps.DB != nil {
watchProviderRegistry := watchsync.NewRegistry()
watchProviderRegistry = watchsync.NewRegistry()
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
@@ -858,10 +907,8 @@ func main() {
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
watchProviderService = watchsync.NewService(
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
watchProviderRegistry,
)
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
deps.WatchProviderService = watchProviderService
}
@@ -929,7 +976,7 @@ func main() {
s.SetLiteraryWorkLinker(literaryWorkService)
s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB))
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second)
slog.Info("scanner initialized")
}
@@ -1073,6 +1120,15 @@ func main() {
installer,
plugins.NewHostAdapter(pluginHost),
)
if watchProviderRegistry != nil {
reloadWatchProviders := func(ctx context.Context) {
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
}
}
pluginService.AddLifecycleHook(reloadWatchProviders)
reloadWatchProviders(appCtx)
}
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
pluginService.AddLifecycleHook(func(ctx context.Context) {
@@ -2040,6 +2096,11 @@ 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))
}
@@ -2426,7 +2487,8 @@ func main() {
deps.S3Private,
itemRefreshExecutor,
libraryRefreshExecutor,
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo),
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo,
librarySettingsCleaner(deps.DB, userStoreProvider)),
adminjob.NewImageCacheCleanupExecutor(deps.S3Public),
templateBundleApplyExecutor,
deps.RealtimeHub,
@@ -3036,11 +3098,100 @@ 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(),
ConnectionConfigSchema: descriptor.GetConfigSchema(),
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
@@ -3256,17 +3407,31 @@ type scopeEntitlementResolver struct {
}
func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := r.resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
scope, err := r.resolveScope(ctx, userID, profileID)
if err != nil {
return "", err
}
return scope.MaxPlaybackQuality, nil
}
// MaxContentRating implements mediarequests.ContentRatingResolver so request
// discovery honors the profile's parental rating ceiling.
func (r scopeEntitlementResolver) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := r.resolveScope(ctx, userID, profileID)
if err != nil {
return "", err
}
return scope.MaxContentRating, nil
}
func (r scopeEntitlementResolver) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) {
return r.resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
}
// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which
// exposes Get) to the audiobooks.SettingsReader interface (which
// requires GetString). The two signatures are identical modulo name.
+106
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -8,9 +10,12 @@ import (
"sync"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
@@ -182,3 +187,104 @@ 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")
}
type staticWatchSyncCapabilityStore struct {
capabilities []*plugins.Capability
}
func (s staticWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
return []*plugins.Installation{{ID: 4, Enabled: true, Kind: plugins.KindPlugin}}, nil
}
func (s staticWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
return s.capabilities, nil
}
func TestReloadWatchSyncPluginProvidersPreservesConnectionForm(t *testing.T) {
manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{
Type: "watch_sync_provider.v1", Id: "floppy", DisplayName: "Floppy",
ConfigSchema: []*pluginv1.ConfigSchema{{
Key: "floppy", Title: "Your Floppy server", Required: true,
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"}},"required":["base_url"]}`,
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
Key: "base_url", Label: "Server URL", Required: true,
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
}}},
}},
WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
},
}}}
records, err := plugins.CapabilityRecordsFromManifest(manifest)
if err != nil {
t.Fatal(err)
}
capabilities := make([]*plugins.Capability, 0, len(records))
for i := range records {
record := records[i]
capabilities = append(capabilities, &record)
}
registry := watchsync.NewRegistry()
if err := reloadWatchSyncPluginProviders(
context.Background(), registry, staticWatchSyncCapabilityStore{capabilities: capabilities}, &plugins.Service{}, nil,
); err != nil {
t.Fatal(err)
}
provider, ok := registry.Get("plugin:4:floppy")
if !ok {
t.Fatal("Floppy provider was not registered")
}
configurable, ok := provider.(interface {
ConnectionConfigSchema() []plugins.ConfigSchemaView
})
if !ok {
t.Fatal("Floppy provider does not expose connection configuration")
}
schemas := configurable.ConnectionConfigSchema()
if len(schemas) != 1 || schemas[0].AdminForm == nil || len(schemas[0].AdminForm.Fields) != 1 ||
schemas[0].AdminForm.Fields[0].Control != "TEXT" {
t.Fatalf("connection config schema = %#v", schemas)
}
}
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())
}
}
+1
View File
@@ -40,5 +40,6 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
PositionSeconds: s.Position,
IsPaused: s.IsPaused,
HasWebSocket: s.HasWebSocket,
IsJellyfinCompat: s.IsJellyfinCompat,
}
}
+672
View File
@@ -0,0 +1,672 @@
{
"fixture_version": 1,
"manifest_revision": 5,
"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"
}
]
}
]
}
+18
View File
@@ -0,0 +1,18 @@
// 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
@@ -0,0 +1,344 @@
{
"$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"
}
}
}
}
}
@@ -0,0 +1,80 @@
{
"$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"
]
}
}
}
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-presentation.json",
"title": "Media card presentation",
"description": "Semantic density and caption presets that each client family maps to platform-appropriate card dimensions.",
"type": "object",
"additionalProperties": false,
"required": ["poster_size", "caption"],
"properties": {
"poster_size": {
"type": "string",
"enum": ["compact", "standard", "large"]
},
"caption": {
"type": "string",
"enum": ["title_metadata", "title", "artwork"]
}
}
}
@@ -0,0 +1,13 @@
{
"$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
}
}
@@ -0,0 +1,32 @@
{
"$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
}
}
}
@@ -0,0 +1,20 @@
{
"$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
}
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/navigation-shortcuts.json",
"title": "Navigation shortcuts",
"description": "Profile-wide catalog of pinned libraries, sections, and collections that client-family menus may surface.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Pinned targets. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"maxItems": 256,
"uniqueItems": true,
"items": {
"oneOf": [
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
@@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/primary-menu.json",
"title": "Primary navigation menu",
"description": "Ordered visible destinations for one client family. Search and profile controls are fixed client utilities and are intentionally absent.",
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: builtin(destination), library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
"type": "object",
"additionalProperties": false,
"required": ["items"],
"properties": {
"items": {
"description": "Ordered destinations. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
"type": "array",
"minItems": 1,
"maxItems": 64,
"uniqueItems": true,
"contains": {
"type": "object",
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": { "const": "home" }
}
},
"minContains": 1,
"maxContains": 1,
"items": {
"oneOf": [
{ "$ref": "#/$defs/builtin" },
{ "$ref": "#/$defs/library" },
{ "$ref": "#/$defs/section" },
{ "$ref": "#/$defs/collection" }
]
}
}
},
"$defs": {
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
"libraryId": { "type": "integer", "minimum": 1 },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
"builtin": {
"type": "object",
"additionalProperties": false,
"required": ["type", "destination"],
"properties": {
"type": { "const": "builtin" },
"destination": {
"type": "string",
"enum": ["home", "movies", "series", "music", "audiobooks", "for_you", "calendar"]
}
}
},
"library": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "label"],
"properties": {
"type": { "const": "library" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
},
"section": {
"type": "object",
"additionalProperties": false,
"required": ["type", "library_id", "section_id", "label"],
"properties": {
"type": { "const": "section" },
"library_id": { "$ref": "#/$defs/libraryId" },
"section_id": { "$ref": "#/$defs/targetId" },
"label": { "$ref": "#/$defs/label" }
}
},
"collection": {
"type": "object",
"additionalProperties": false,
"required": ["type", "collection_id", "label"],
"properties": {
"type": { "const": "collection" },
"collection_id": { "$ref": "#/$defs/targetId" },
"library_id": { "$ref": "#/$defs/libraryId" },
"label": { "$ref": "#/$defs/label" }
}
}
}
}
@@ -0,0 +1,27 @@
{
"$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 }
}
}
}
}
@@ -0,0 +1,45 @@
{
"$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}$"
}
}
}
@@ -0,0 +1,18 @@
{
"$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
}
+16
View File
@@ -18,6 +18,22 @@ 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. |
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
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
# Non-goals
Silo is an early WIP and most of its scope is open. This document is the short list of things
that are **not** open — features that will not be accepted, in core, in a plugin, or in a client,
regardless of implementation quality.
These are product-direction decisions, not "not yet" or "needs a better design." A well-written
proposal or a working PR does not change the answer. Please read this before opening an issue or
writing code in these areas, so nobody's time is wasted.
## Live TV, OTA tuners, IPTV, and remote stream shortcuts
**Out of scope permanently.** This covers, at minimum:
- Live TV of any kind, including OTA tuners (HDHomeRun and equivalents) and DVB devices.
- IPTV: M3U/M3U8 playlist ingestion, provider portals, Xtream-style APIs, stream relays.
- EPG/guide ingestion, XMLTV import, guide-provider sync, and channel lineup management.
- DVR: scheduled recordings, series rules, tuner arbitration.
- `.strm` files and any equivalent shortcut format whose contents are a remote media URL to be
fetched, proxied, redirected to, or transcoded by the server.
### Why
**App store distribution risk.** Silo's first-party clients ship on the Apple and Google stores.
Review of media apps in these categories is strict, and an app whose server plays arbitrary
remote stream URLs or IPTV playlists is read as a conduit for unlicensed content no matter what
the intended use is. The exposure is not scoped to the feature — a rejection or a takedown lands
on the entire client suite, for every user, including those who only ever play files they own.
No single feature is worth that trade.
**Product scope.** Silo indexes and plays media that exists on disk in a library the operator
controls. Live TV is a different product with its own always-on reliability burden: tuner
allocation, guide-provider contracts and merge semantics, schedule conflict resolution, and
recording lifecycle. Building it competes directly with making the core playback path fast and
correct, which is the stated priority.
**`.strm` is the same feature with a smaller diff.** A `.strm` file is a text file containing a
remote URL. Supporting it turns the server into a fetcher/transcoder/redirector for arbitrary
remote streams. The small change footprint is exactly what makes it worth naming explicitly here:
it is the capability without the label.
### Not available as a plugin either
The plugin runtime is not an escape hatch for this. Anything in this list needs first-party
client UI to be usable, which puts it back in the app store listings and reproduces the exact
problem above. Not in core, not in a plugin, not in a client.
### What to do instead
Silo does not assume it is the only media server on the box. Run Jellyfin, Plex, or a dedicated
tuner/IPTV backend alongside it for live TV, and use Silo for the on-disk library. That
separation also keeps the risk where it belongs, on software whose distribution model can absorb
it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+198
View File
@@ -0,0 +1,198 @@
# Canonical Settings API
The canonical settings API stores typed user preferences from the shared
settings contract. Client implementations should discover the server contract
before rendering controls or writing a value; do not keep a separate list of
keys, scopes, enum members, or defaults.
All paths below are relative to `/api/v1`.
## Contract discovery
| Method and path | Purpose |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /settings/manifest` | Public client projection of the current manifest. Supports `If-None-Match`. |
| `GET /settings/capability` | Contract API version, revision, remote scopes, supported client families, and batch/write capabilities. |
`/settings/contract` and `/settings/contract/capabilities` are equivalent
aliases. A client whose vendored contract is newer than the advertised server
revision must hide definitions and features introduced after that revision.
Navigation shortcut mutation controls additionally require
`supports_atomic_shortcuts: true`. Revision-5 customization also requires
`supports_batched_effective: true` and `supports_idempotent_writes: true` so
batched resolution and replayed writes have the semantics the clients depend
on. Any missing flag fails closed.
## Request identity headers
Authenticated settings routes use the active account and profile from the
normal Silo session. The contextual headers below identify which client is
resolving or writing an override.
| Header | When required | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Profile-Id` | All `/settings/values` routes | Active household profile. |
| `X-Silo-Device-Id` | A `profile_device` explicit request, or an effective request containing a key that permits `profile_device` | Stable exact-device identity. |
| `X-Silo-Client-Family` | Required for a `profile_client` explicit request; optional on effective reads | Closed like-client identity: `tv`, `mobile`, `tablet`, `desktop`, or `web`. A valid value includes the like-client layer; absence skips it. |
| `X-Silo-Mutation-Id` | Optional on `PUT` | Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict. |
Client family is intentionally independent of the free-form
`X-Silo-Device-Platform` display metadata. The server never guesses one from
the other. Send the exact lower-case family:
| Client | Family |
| ----------------------- | --------- |
| tvOS or Android TV | `tv` |
| iPhone or Android phone | `mobile` |
| iPad or Android tablet | `tablet` |
| macOS | `desktop` |
| Browser | `web` |
## Remote scopes
Every stored value has exactly one identity. Context fields not named by the
selected scope must be absent.
| Scope | Identity after account |
| ----------------- | ----------------------------- |
| `account` | none |
| `profile` | `profile_id` |
| `profile_client` | `profile_id`, `client_family` |
| `profile_device` | `profile_id`, `device_id` |
| `profile_library` | `profile_id`, `library_id` |
| `profile_series` | `profile_id`, `series_id` |
The manifest's `allowed_scopes` decides where each key may be written, and its
`resolution_order` decides precedence. `profile_client` values roam only among
like clients. For example, a TV value applies to tvOS and Android TV but not to
a phone or browser.
## Explicit values
Use explicit endpoints to edit or clear one scope, without resolving inherited
values:
- `GET /settings/values?keys=<csv>&scope=<scope>` returns every requested key
with `is_set`; unset rows remain in the response.
- `GET /settings/values/{key}?scope=<scope>` returns one stored value or `404`.
- `PUT /settings/values/{key}?scope=<scope>` accepts `{"value": <typed JSON>}`.
- `DELETE /settings/values/{key}?scope=<scope>` removes the row so inheritance
applies again.
`library_id` and `series_id` are query parameters for their matching scopes.
An explicitly managed device may be named with `device_id`, subject to the
profile/device authorization checks. The self-service `profile_client` scope
takes its family only from `X-Silo-Client-Family`.
Example: share a TV menu between tvOS and Android TV clients.
```http
PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Client-Family: tv
Content-Type: application/json
{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}
```
Stored-value responses include `client_family` when the source or explicit row
is at `profile_client`.
### Atomic navigation shortcuts
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
web clients. Self-service clients must mutate one destination at a time instead
of replacing that shared document:
```http
PUT /api/v1/settings/values/nav.shortcuts/item
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
Content-Type: application/json
{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}
```
The item is one member of `navigation-shortcuts.json`: a library, section, or
collection (whose `collection_id` is a string and whose `library_id` is
optional). Identity is exactly the schema's semantic identity:
- library: `type + library_id`
- section: `type + library_id + section_id`
- collection: `type + optional library_id + collection_id`
`present: true` appends an absent item or refreshes the label of an existing
identity in place without reordering it. `present: false` removes that identity;
the supplied label is validated but ignored for matching. The server atomically
rebases on concurrent edits, enforces the full schema and 256-item cap, and
returns the normal stored-value object containing the complete resulting
`value`, row `revision`, and `updated_at`. An already-satisfied operation is a
successful no-op and does not increment the revision. Removing from a catalog
that has never been stored returns `{"items":[]}` at revision `0` with no
`updated_at`.
Retry with the same `X-Silo-Mutation-Id`. A recorded retry returns the original
response with `X-Silo-Idempotent-Replay: true`; reusing the id for a different
semantic operation returns `409 mutation_id_conflict`. The mutation ID is
serialized before the setting write, and the setting plus its replay receipt
commit in one database transaction, so concurrent reuse cannot apply twice and
a crash cannot persist only one half. A rare exhausted
contention loop returns retryable `409 setting_update_conflict`. Malformed
envelopes or unknown envelope fields return `400 bad_request`, while item
schema failures (including unknown item fields) and cap failures return
`400 invalid_value`.
Ordinary session `PUT` and `DELETE` at
`/settings/values/nav.shortcuts?scope=profile` are rejected with
`400 atomic_update_required` so a whole-document mutation cannot erase
concurrent item edits or reset the row revision. The admin endpoint retains
whole-document `PUT` for explicit repair work, but rejects physical `DELETE`
for the same revision-history reason. An admin clears the catalog with
`{"value":{"items":[]}}`, which advances the row revision, or replaces it with
another validated document.
## Effective values
- `GET /settings/values/effective?keys=<csv>` resolves several keys for the
request profile, device, and client family. Omitting `keys` resolves all
remote definitions.
- `POST /settings/values/effective` resolves a bounded list of content contexts
in one store read. Use it for grids or lists rather than issuing one request
per item.
An effective request must include the device header when any requested
definition permits an exact-device override. The family header is optional for
backward compatibility: absence skips the `profile_client` layer, while a
non-empty invalid family is rejected. First-party clients should send their
family so like-device preferences participate in resolution. Each response
includes its source scope and source context; `client_family` is included for a
family-scoped winner.
## Admin projection
Admin routes are mounted behind the normal acting-admin authorization:
- `GET /admin/users/{id}/settings/values` lists every stored row across all
scopes.
- `PUT /admin/users/{id}/settings/values/{key}?scope=<scope>` writes through the
same contract validation and normalization as the self-service endpoint.
- `DELETE /admin/users/{id}/settings/values/{key}?scope=<scope>` clears the
selected row.
Admin requests name profile/context identity with query parameters because the
target user is not the admin's active session. In particular,
`scope=profile_client` requires both `profile_id` and `client_family` query
parameters; it does not use `X-Silo-Client-Family`.
```http
PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
Authorization: Bearer <admin-token>
Content-Type: application/json
{"value":{"poster_size":"large","caption":"artwork"}}
```
The five accepted `client_family` values are also returned by the capability
endpoint so admin tooling does not need to invent them.
@@ -0,0 +1,306 @@
# Implementation Plan: Emailed Invitations &amp; Server-Driven Onboarding
**Spec:** `docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md`
**Mockups:** `docs/design/invite-onboarding.html`
**Date:** 2026-07-27
Commands assume the repository root is the cwd.
## Shape of the work
Two features that share a journey but not a dependency. **Part 1
(invitations) and Part 2 (onboarding) are independently shippable** — build
and merge them in that order, but neither blocks the other if priorities
shift.
Phases 1–4 are silo-server and ship as one PR each. Phases 5–6 are the client
repos and follow after the server contract is merged and stable.
Resolve the spec's three open questions before starting Phase 1 — expiry
configurability, replay entry point, and `tour_id` re-prompt semantics all
affect the schema.
---
# Part 1 — Invitations (silo-server)
## Phase 1 — Migration
`make migrate-create NAME=invitations` — one timestamped Goose migration, no
hand-numbering, no paired up/down files.
Up: the `invitations` table exactly as specced, plus the two indexes. The
partial unique index is the load-bearing one:
```sql
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;
```
It is what makes "resend supersedes" true at the database level rather than
only in application code.
Down: `DROP TABLE public.invitations;`.
Verify with `make migrate-up && make migrate-status`.
## Phase 2 — Backend: model, repo, mail, service
New package `internal/invitations`. It owns the whole feature; nothing goes in
a catch-all helper.
**`internal/models/invitation.go`** — `Invitation`, `CreateInvitationInput`,
and a derived `Status()` method returning `pending|accepted|expired|revoked`
computed from the timestamps. No status column.
**`internal/invitations/repository.go`** — CRUD plus:
- `Create` — mints the token, stores only the SHA-256 hex. Returns the raw
token to the caller once and never again.
- `GetByTokenHash` — the claim lookup.
- `Accept(ctx, tokenHash, userID)` — `UPDATE ... WHERE token_hash = $1 AND
accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now()`. Zero
rows affected means already-used/expired/revoked; the caller distinguishes
by re-reading. This is the concurrency guard for double-submit.
- `Revoke`, `List`.
Mirror the token helpers in `internal/notifications/email_address.go`
(`newEmailToken` / `hashEmailToken`) rather than reinventing them — consider
lifting them to a shared spot if the duplication grates, but do not
restructure `notifications` as a side effect of this work.
**`internal/invitations/email.go`** — `composeInvitationEmail`, built from
`mail.RenderLayout` / `EmailParagraph` / `EmailButton`
(`internal/mail/layout.go:88`). Escape the admin note. Link base resolution
reuses the `notifications.email.external_url` → public URL precedence.
**`internal/invitations/service.go`** — orchestration:
- `Send` — validates the address, supersedes any pending invite for it,
creates, sends. On `mail.ErrNotConfigured`, returns the row plus the claim
URL and `emailSent=false` rather than erroring. Rejects a `role: admin`
invitation from a non-admin inviter (enforce at the service, not the UI).
- `Lookup` — claim-screen projection only: inviter display name, email,
expiry. Nothing else leaves the server.
- `Accept` — one transaction: `auth.AccountProvisioner.CreateAccount`
(`internal/auth/account_provisioner.go`, which already rolls back the user if
profile creation fails) with the pre-bound role/group/libraries, then
`repository.Accept`, then log in via the existing session path so the
response is a normal `TokenPair`.
**Login fallback** — `internal/auth/provider.go:56`. `LocalProvider.Authenticate`
currently calls `GetByUsername` only. Add: on not-found, if the input parses as
an email address (`net/mail.ParseAddress`), retry `GetByEmail`. Keep the
password comparison path identical so timing does not diverge between the two
lookups.
Tests:
- Accept twice concurrently → exactly one account, one 4xx.
- Accept an expired invitation → refused, no user row.
- Resend invalidates the prior token.
- Email login fallback resolves; username login is unchanged.
- Non-admin inviter cannot mint an admin invitation.
## Phase 3 — Backend: HTTP
**`internal/api/handlers/admin_invitations.go`** — list/create/resend/revoke,
following `admin_invite_codes.go` for shape and error mapping.
**`internal/api/handlers/invitations.go`** — the two public endpoints.
`GET /invitations/{token}` returns an identical 404 for unknown, expired,
revoked, and accepted; do not leak which.
**`internal/api/router.go`**:
- Admin routes beside `/invite-codes` (~line 2891), same admin gate.
- Public routes beside `/auth`, wrapped in
`deps.RateLimitMW.AuthEndpointHandler("invitation")` where the limiter is
present, matching the `login`/`signup` treatment at lines 1708–1716.
`accept` returns `buildLoginResponse` — the same shape as signup — so no
client grows a parallel auth path.
## Phase 4 — Web: admin tab + claim page
**`web/src/pages/admin-settings/InvitationsTab.tsx`** — table plus composer
dialog, per the mockup. Follow `InviteCodesTab.tsx` for structure and the
`useAdminInviteCodes` hooks for the query layer. Register the tab in
`AdminUsers.tsx` beside the existing `invite-codes` tab.
When the create response carries `email_sent: false`, swap the success toast
for a copyable link and say plainly that email is not configured.
**`web/src/pages/InviteClaim.tsx`** — public route `/invite/:token`, added to
`App.tsx` beside `/signup` (line 373). Reuse `auth-shell`, `AuthBackground`,
and the `PasswordInput` component. Expired/used/revoked renders an explanatory
card with a sign-in link, never a bare 404.
On success, store the returned tokens through the existing auth hook and route
onward — do not bounce the user back to `/login`.
**`web/src/pages/HouseholdSetup.tsx`** — the "Who's watching?" step, routed to
directly after a successful accept. **No new backend**: each tile posts through
the existing `POST /profiles` mutation, whose request already carries name,
avatar, PIN, `is_child`, `max_content_rating`, and per-profile library
restrictions (`createProfileRequest`, `internal/api/handlers/profiles.go:50`).
- The first tile is the invitee's own profile, pre-named from the email local
part, renameable inline.
- The add-profile dialog per the mockup: kids toggle reveals rating ceiling
(default PG) + library picker; PIN toggle carries the "pin the adults, not
the kids" copy.
- "Just me for now" exits without creating anything.
- Reuse the avatar preset picker and PIN dialog pieces from
`ProfilesSettings.tsx` rather than duplicating them — extract shared
components if needed.
Add `Invitation` types to `web/src/api/types.ts` and hooks under
`web/src/hooks/queries/admin/invitations.ts`.
---
# Part 2 — Onboarding (silo-server, then clients)
## Phase 5 — Backend: manifest + state
**userdb schema** — add `profile_onboarding` keyed `(profile_id, tour_id)`
with `last_step`, `completed_at`, `skipped_at`. This needs a
`schemaVersion` bump in `internal/userdb/migrate.go` (currently 13) plus the
matching migration step; follow how the existing per-profile tables
(`subtitle_preferences`, `series_playback_preferences`) were added. Do **not**
use `user_settings` — it is keyed by `key` alone and therefore account-wide,
so one profile finishing would silence the tour for the whole household.
**`internal/onboarding`** — new package:
- `steps.go` — the manifest as Go data. Copy lives here.
- `filter.go` — per-server and per-surface filtering. Query the real feature
gates: `requests_enabled` via `internal/requests`, watch-together, whether
`mail.Sender.Enabled()` or push is configured, recommendations. Drop
`spotlight` and any text-entry step for `surface=tv`.
- `state.go` — read/write via the userdb store.
**Handlers + routes** — `GET /onboarding/flow`, `GET /onboarding/state`,
`POST /onboarding/progress`, all profile-scoped (`apimw.RequireProfile`, as the
`/settings/effective` group does at router.go:2242).
The manifest must only emit a `setting_choice` the current profile may write —
check child-profile restrictions before including a step, rather than letting
the client discover the refusal.
Tests: a server with requests disabled omits the requests step; `surface=tv`
omits spotlights; completing on one profile does not mark another complete.
## Phase 6 — Web: the tour
**`web/src/components/onboarding/`** — `TourHost` (the state machine plus
progress POSTs) and one renderer per step kind. Unknown `kind` → skip, no
error. This is the compatibility guarantee; test it explicitly with a
fabricated future kind.
`setting_choice` dispatches on `target`:
- `profile_field` → `PUT /profiles/{id}` via the existing update mutation
(see `saveProfileField`, `web/src/pages/settings/PlaybackSettings.tsx:114`)
- `setting` → the existing `useSetSetting`
- `device_setting` → the existing device-setting mutation
Reusing those mutations means the tour writes the same rows the settings
screens write and inherits their cache invalidation for free.
**Entry** — extend the existing `TasteSeedGate` in `App.tsx` (line 234) into an
onboarding gate that checks server state first, runs the tour, then hands off
to `/taste-seed` via the `handoff` step. The tour precedes taste-seed rather
than replacing it.
**Replay** — an entry point in Settings → Personalize (pending the spec's open
question 2).
## Phase 7 — Android (silo-android)
Separate repo, separate PR, after the server contract is merged.
- Models + client method for the three onboarding endpoints in
`shared/src/commonMain/kotlin/.../model`, alongside the existing auth models.
- Compose tour UI in `androidApp/.../ui/screens/onboarding/`: a full-screen
`HorizontalPager`, one step per page, skip always reachable. Unknown kinds
filtered out at parse time.
- Invite deep link: add an `invite` host to the existing `silo` scheme
`intent-filter` in `AndroidManifest.xml` (lines 49–60) plus an `https` App
Link, and a claim screen beside `SignupScreen.kt`. Parse via the existing
`ContentDeepLinkRoutes` machinery.
- Household setup screen after claim: profile tiles + add-profile sheet,
posting through the existing profiles endpoint. The kid preset mirrors the
web dialog (rating ceiling + libraries).
- TV (`androidTvApp`) requests `surface=tv` and renders the focus-driven
variant — no keyboard entry.
## Phase 8 — Apple (silo-apple)
Separate repo, separate PR. **Build and validate on `mac-builder` through the
`xcodebuildmcp` MCP server**, preserving the exact commit plus dirty and
untracked state — do not validate against a stale remote checkout.
- Networking models beside `DeviceLoginModels.swift`.
- `Screens/Onboarding/` — SwiftUI `TabView(.page)` tour reusing the Aurora
design system (`AuroraScreen`, `AuroraPrimaryButtonStyle`). Unknown kinds
filtered at decode.
- Claim screen in `Screens/Auth/` beside `SignupView.swift`, reusing
`AuroraJourneyProgress` (`DesignSystem/Aurora/AuroraStyle.swift:61`) with the
invite path as step 2. Note `AuroraJourneyProgress` hardcodes
`["Server", "Account", "Profile"]` — it needs a labels parameter for the
invite journey (`Server → Password → Household`).
- Household setup after claim, adjacent to the existing
`Screens/Profiles/ProfileSelectionView.swift`: tiles + add-profile sheet
posting through the existing profiles endpoint, kid preset matching web.
- Deep link: add an `invite` case to the `onOpenURL` handler in `iOSApp.swift`
(line 51), which already queues links until auth settles. Universal links
additionally need the server to serve
`/.well-known/apple-app-site-association` — a small server-side follow-up,
not a blocker for the custom scheme.
- tvOS requests `surface=tv`.
---
## Verification
Per server PR:
```bash
make lint
cd web && pnpm run lint && pnpm run format:check
make verify-local-paths
```
Plus `make migrate-up` / `make migrate-status` for Phases 1 and 5, and browser
verification of the web flows via the `web-ui-testing` skill with screenshots
for the PR.
## Commit sequence
One PR per phase, Conventional Commit subjects, one concern each:
| Phase | Repo | Subject |
| --- | --- | --- |
| 1–3 | silo-server | `feat(invitations): add emailed pre-provisioned invitations` |
| 4 | silo-server | `feat(web): add invitation admin tab and claim page` |
| 5 | silo-server | `feat(onboarding): add server-driven onboarding manifest` |
| 6 | silo-server | `feat(web): add first-run feature tour` |
| 7 | silo-android | `feat(onboarding): add invite claim and feature tour` |
| 8 | silo-apple | `feat(onboarding): add invite claim and feature tour` |
Phases 1–3 are one PR: the migration, repo, service, and handlers are a single
coherent unit and splitting them leaves a table nothing reads.
Each PR needs `Part of #NNN` against the capability epic, the AI-use disclosure
block per `docs/ai-contributions.md`, and screenshots for the UI phases.
## Risks
**Login lookup change (Phase 2)** is the highest-risk item — it touches the
path every existing user authenticates through. It is additive (fallback only
on miss), but it warrants explicit regression tests for username login and a
careful review.
**userdb schema bump (Phase 5)** changes per-user SQLite stores. Follow the
existing migration test pattern (`internal/userdb/migrate_v13_test.go`) and
verify an older store upgrades cleanly.
**Client contract drift** is bounded by "unknown kinds are skipped" — but that
only holds if each client actually implements the skip. Test it on all three
before the first post-launch manifest change, not after.
@@ -0,0 +1,206 @@
# Transcode Resolution Clamp 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.
**Goal:** Prevent encoded-video transports from targeting a resolution above the final effective source file.
**Architecture:** Add one pure resolution-normalization helper in the playback API handler and invoke it exactly once after alternate-file selection. Mutate the request's target resolution at that boundary so remote transport, local transport, persistence, activity reporting, and reconstruction consume the same normalized recipe.
**Tech Stack:** Go, `net/http` handler tests, Silo playback session and transcode-node test doubles.
## Global Constraints
- Do not apply the new clamp to video-copy requests; preserve their existing
downstream recipe normalization.
- Preserve empty or unrecognized requested/source resolutions unchanged.
- Clamp only recognized encoded-video targets above the recognized effective source.
- Do not alter bitrate, codecs, API shapes, status codes, settings, migrations, clients, or production configuration.
- Prove the regression RED before writing production code.
---
### Task 1: Normalize the Effective Transcode Recipe
**Files:**
- Modify: `internal/api/handlers/playback.go`
- Test: `internal/api/handlers/playback_test.go`
**Interfaces:**
- Consumes: `transcodeStartRequest.TargetResolution`, `transcodeStartRequest.TargetCodecVideo`, and `models.MediaFile.Resolution` after alternate-file selection.
- Produces: `clampEncodedTargetResolution(requestedResolution, sourceResolution string) string`.
- Produces: one normalized `req.TargetResolution` consumed unchanged by remote/local transport construction and `buildTranscodeSessionReplacement`.
- [ ] **Step 1: Strengthen the existing remote fallback test**
Change `TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback` to
request 2160p at 10000 kbps. Assert that the captured
`transcodenode.TranscodeStartRequest.TargetResolution` is `1080p`, while the
effective alternate remains file 99 and the resulting playback session stores
`TargetResolution == "1080p"`.
```go
strings.NewReader(`{"session_id":"` + startResp.SessionID +
`","seek_seconds":0,"target_resolution":"2160p",` +
`"target_codec_video":"h264","target_codec_audio":"aac",` +
`"target_bitrate_kbps":10000,"segment_duration":2,` +
`"subtitle_track_index":-1,"subtitle_burn_in":false}`)
if remoteStartReq.TargetResolution != "1080p" {
t.Fatalf("remote target resolution = %q, want 1080p", remoteStartReq.TargetResolution)
}
if session.TargetResolution != "1080p" {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
```
- [ ] **Step 2: Run the regression and verify RED**
Run:
```bash
go test ./internal/api/handlers -run TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback -count=1
```
Expected: FAIL because the captured remote request still contains `2160p`.
- [ ] **Step 3: Add pure normalization table tests**
Add `TestClampEncodedTargetResolution` beside the handler tests. Cover:
```go
tests := []struct {
name, requested, source, want string
}{
{"clamps 2160p to 1080p", "2160p", "1080p", "1080p"},
{"keeps lower target", "720p", "1080p", "720p"},
{"keeps equal target", "1080p", "1080p", "1080p"},
{"keeps empty target", "", "1080p", ""},
{"keeps unknown target", "source", "1080p", "source"},
{"keeps target for unknown source", "2160p", "native", "2160p"},
{"supports low tiers", "480p", "420p", "420p"},
}
```
- [ ] **Step 4: Add the minimal helper and post-selection call**
Add a package-level recognized-height map/helper near `resolutionRank`:
```go
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
return requestedResolution
}
return sourceResolution
}
```
After the 4K alternate block and before transport planning:
```go
if !videoCopy {
req.TargetResolution = clampEncodedTargetResolution(
req.TargetResolution,
file.Resolution,
)
}
```
Use an exhaustive switch for the six supported transcode tiers. Do not modify
the FFmpeg filter builder or web client.
- [ ] **Step 5: Verify focused GREEN**
Run:
```bash
go test ./internal/api/handlers \
-run 'TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback|TestClampEncodedTargetResolution' \
-count=1
```
Expected: PASS.
- [ ] **Step 6: Add copy and local-boundary regression coverage**
Add focused tests proving:
- a copy-video request bypasses the new clamp and retains its existing
downstream empty-resolution recipe;
- a local encoded-video request receives the clamped target in the persisted
session and generated FFmpeg recipe boundary;
- a requested 720p encode for a 1080p effective file remains 720p.
Use the existing local fake-FFmpeg and session-manager patterns in
`playback_test.go`; do not introduce production test hooks.
- [ ] **Step 7: Run focused and package verification**
Run:
```bash
gofmt -w internal/api/handlers/playback.go internal/api/handlers/playback_test.go
go test ./internal/api/handlers -count=1
go test ./internal/playback -count=1
go test ./internal/api/handlers ./internal/playback -race -count=1
git diff --check
```
Expected: all commands exit 0.
- [ ] **Step 8: Commit implementation**
```bash
git add internal/api/handlers/playback.go internal/api/handlers/playback_test.go
git commit -m "fix(playback): prevent transcode resolution upscaling"
```
### Task 2: Independent Review and Final Verification
**Files:**
- Review: `docs/superpowers/specs/2026-07-28-transcode-resolution-clamp-design.md`
- Review: `internal/api/handlers/playback.go`
- Review: `internal/api/handlers/playback_test.go`
**Interfaces:**
- Consumes: committed spec and implementation diff from `upstream/main`.
- Produces: reviewer verdict and a clean, verified branch suitable for a separate pull request.
- [ ] **Step 1: Request focused correctness/security review**
Review the change against these invariants:
- no encoded output exceeds the effective source tier;
- copy and unknown-resolution compatibility is preserved;
- local, offloaded, persisted, and reconstruction paths share one value;
- no target-bitrate or API behavior was broadened;
- tests fail without the production correction.
- [ ] **Step 2: Address Critical or Important findings test-first**
For each accepted behavioral finding, add or strengthen a failing test, observe
RED, make the smallest production correction, and rerun focused GREEN. Do not
bundle unrelated cleanup.
- [ ] **Step 3: Run final verification**
Run:
```bash
go test ./internal/api/handlers ./internal/playback -count=1
go test ./internal/api/handlers ./internal/playback -race -count=1
go test ./... -count=1
make verify-local-paths
git diff --check
git status --short --branch
```
Expected: tests and repository policy checks exit 0; status contains no
uncommitted implementation changes.
- [ ] **Step 4: Prepare handoff**
Summarize the production evidence, exact normalization boundary, RED/GREEN
proof, focused/full verification, reviewer verdict, and deployment caveat.
Do not deploy or mutate production as part of this plan.
@@ -0,0 +1,510 @@
# 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.
@@ -0,0 +1,317 @@
# 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.
- Allow API-key providers to declare setup fields that are supplied per profile
connection rather than forcing one installation-wide endpoint.
- 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`. A plugin capability may also declare `config_schema`
entries; the host renders those fields beside the API key, validates submitted
objects against the declared JSON schemas, classifies secret fields, and
overlays the flattened values onto `provider_config` for that exchange only.
The plugin validates or exchanges the credential and returns normalized
credentials plus provider account identity. Provider-specific connection
context needed by later calls is returned in encrypted opaque credential
attributes. Device-code providers use the existing start and poll routes.
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.
Capability-level `config_schema` entries are connection setup fields, not
installation defaults. For API-key authorization, the host validates and
flattens them using the same `<config-key>.<field>` convention and lets the
connection values override a same-named global value. This supports migrations
from a legacy installation-wide endpoint while ensuring every new connection
chooses its own endpoint. The submitted objects are not retained as generic
plugin configuration; the plugin returns any context required after exchange
inside its opaque encrypted credentials.
## 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.
@@ -0,0 +1,484 @@
# Emailed Invitations &amp; Server-Driven Onboarding — Design Spec
**Date:** 2026-07-27
**Status:** Draft — for review
**Mockups:** `docs/design/invite-onboarding.html`
**Surfaces:** silo-server, silo-server/web, silo-android, silo-apple
## Problem
Two gaps, one journey.
**Getting in.** Today an admin creates a multi-use `invite_codes` row and hands
the string to someone out-of-band. That person visits `/signup`, invents a
username, retypes their email, retypes the code, and lands on an empty home
screen. Nothing is scoped ahead of time — library access, group, and role are
whatever the defaults are, so the admin edits the user afterward. There is no
per-person revocation: disabling a code disables it for everyone who has it.
**Staying.** Silo has a set of features most self-hosted media servers do not
ship at all — watch together, media requests, watchlists with arrival
notifications, taste-based recommendations, a release calendar, per-library and
per-series playback overrides, subtitle appearance that follows the profile.
A new user discovers none of it. The only onboarding that exists is the
taste-seed picker (`web/src/pages/TasteSeed.tsx`), which is web-only and
covers exactly one feature.
This spec covers both: a personal, pre-provisioned invitation delivered by
email, and a first-run tour whose content the server owns so all three client
platforms stay in sync.
## Non-goals
- Replacing `invite_codes`. Shareable codes stay exactly as they are for the
"drop a code in Discord" case. This adds a second, complementary mechanism.
- Self-service password reset. Adjacent and worth doing, but a separate
concern with its own threat model. The token table here is deliberately
shaped so a reset flow can reuse it later (see "Future work").
- SSO/OAuth invitations. Plugin auth providers own their own account
provisioning; an invitation targets local credentials only.
- An admin-authorable tour editor. The manifest is server-owned but built in
Go, not user-editable. Admins get on/off toggles, not a CMS.
---
# Part 1 — Invitations
## Model
One new table. An invitation is a **capability token bound to one email
address**, carrying the access decisions the admin already made.
```sql
CREATE TABLE public.invitations (
id bigserial PRIMARY KEY,
email citext NOT NULL,
token_hash text NOT NULL UNIQUE,
-- Pre-bound access, applied verbatim at accept time.
role text NOT NULL DEFAULT 'user',
access_group_id bigint REFERENCES public.access_groups(id) ON DELETE SET NULL,
library_ids integer[], -- NULL = all libraries
create_profile boolean NOT NULL DEFAULT true,
show_tour boolean NOT NULL DEFAULT true,
note text NOT NULL DEFAULT '',
-- Lifecycle.
invited_by bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
accepted_user_id bigint REFERENCES public.users(id) ON DELETE SET NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX invitations_email_idx ON public.invitations (email);
-- At most one live invitation per address. Re-inviting supersedes rather
-- than accumulating parallel valid tokens for the same person.
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;
```
Token handling mirrors the email-verification flow that already exists in
`internal/notifications/email_address.go`: 32 random bytes,
`base64.RawURLEncoding` for the URL, **SHA-256 hex stored at rest**. The raw
token exists in the sent email and nowhere else — a database dump does not
yield usable invitation links.
### Why no user row until accept
An unaccepted invitation creates nothing in `users`. A typo'd address cannot
squat a username, cannot appear in the user list as a permanent ghost, and
cannot be counted against any seat/limit logic added later. The
`invitations_one_pending_idx` partial unique index is what keeps "resend"
honest: it supersedes, so a forwarded copy of the old link stops working.
### Email as username
The invitee sets only a password. `username` is set to the invitation's email
address. This works today without a schema change: `users.username` and
`users.email` are both `citext` with unique constraints (migration
`165_case_insensitive_username_email.sql`), so `marco@example.com` is a legal
username and matches case-insensitively.
One behavior change is required for this to feel right:
> **`LocalProvider.Authenticate` must accept an email address in the username
> field.** `internal/auth/provider.go:56` calls `users.GetByUsername` only. It
> gains an email fallback: if the username lookup misses **and** the input
> parses as an email address, retry via `GetByEmail`.
This is strictly additive — existing username logins are unaffected, and there
is no ambiguity risk because both columns are globally unique across the same
identity space. Without it, someone invited by email who later types their
address on the phone app gets "invalid credentials" and has no way to discover
that their username happens to be identical to the thing they just typed.
Login rate limiting already covers this endpoint (`AuthEndpointHandler("login")`),
so the extra lookup does not open an enumeration channel that the single lookup
did not already have.
## Endpoints
Admin, under the existing `/api/v1/admin` tree (admin-gated, same as
`/admin/invite-codes`):
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/admin/invitations` | List, newest first, with derived status. |
| `POST` | `/admin/invitations` | Create + send. Returns the row and, when email is off, the raw claim URL. |
| `POST` | `/admin/invitations/{id}/resend` | Supersede: revoke, mint fresh token, resend. |
| `DELETE` | `/admin/invitations/{id}` | Revoke. Idempotent. |
Public, under `/api/v1`, unauthenticated, **rate-limited via the existing
`deps.RateLimitMW.AuthEndpointHandler`** alongside login/signup:
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/invitations/{token}` | Claim-screen data only: inviter display name, email, expiry, server name. |
| `POST` | `/invitations/{token}/accept` | Create the account, redeem, return a session token pair. |
`accept` returns the **same `loginResponse` shape as signup**
(`buildLoginResponse`), so every client reuses its existing session plumbing
rather than growing a parallel auth path.
### Status is derived, not stored
`Pending | Accepted | Expired | Revoked` is computed from
`accepted_at`/`revoked_at`/`expires_at` at read time. No status column, no
background job to expire rows, no possibility of the column disagreeing with
the timestamps.
### Accept is one transaction
Create user → apply pre-bound access → create the default profile → mark the
invitation accepted. `auth.AccountProvisioner.CreateAccount` already handles
user-plus-profile with rollback-on-profile-failure
(`internal/auth/account_provisioner.go`), so this reuses it and adds the
invitation update. A concurrent double-submit loses on the
`accepted_at IS NULL` predicate and returns "already used".
## Email
Composed in a new `internal/invitations` package using `internal/mail`'s
`RenderLayout`, `EmailParagraph`, and `EmailButton`, exactly as
`composeVerificationEmail` does. Same SMTP settings, same diagnostics, same
branded shell.
Link base resolution reuses the established precedence:
`notifications.email.external_url` → server public URL. When neither is set,
`POST /admin/invitations` still creates the row and returns the claim URL with
a `email_sent: false` flag; the admin UI shows **"Copy invite link"** instead
of pretending mail went out. Failing loudly beats a silent drop.
The email states the sign-in address explicitly. It is the one fact the person
will need again when they install the phone app, and the one they would
otherwise guess wrong.
## Deep links
The claim URL is `https://<server>/invite/<token>`.
- **Web** — a new public route, sibling to `/signup` and `/activate` in
`web/src/App.tsx`.
- **Android** — the `silo` scheme already routes `device`, `item`, `play`, and
`downloads` hosts in `AndroidManifest.xml`. Add an `invite` host plus an
`https` App Link on the server's domain, parsed by the existing
`ContentDeepLinkRoutes` machinery.
- **Apple** — `onOpenURL` already posts to a notification the root view
consumes and queues until auth settles (`iOSApp.swift`). Add an `invite`
case; universal links need the server to serve
`/.well-known/apple-app-site-association`, which is new work.
Crucially, the deep link carries **both the server URL and the token**, so the
app skips its "which server?" step entirely — that is most of the friction in
onboarding someone onto a self-hosted service.
## Household setup ("Who's watching?")
Between the password and the tour sits a household step: profiles for
everyone on the couch — a spouse, kids, grandparents. The journey indicator
reads **Invite → Password → Household**.
**This step needs no new backend.** `POST /api/v1/profiles` already carries
everything the screen sets: name, preset avatar, PIN, `is_child`,
`max_content_rating`, and per-profile library restrictions
(`createProfileRequest`, `internal/api/handlers/profiles.go:50`). The work is
purely presentational — surfacing the existing profile model at the moment it
matters instead of leaving it buried in Settings → Profiles.
Design decisions:
- **Profiles are not logins, and the screen says so.** One account, one
password, many viewers — several profiles per `user_id` is the existing
model. The copy heads off the "do I need to invite my wife separately?"
question. (If she *should* have her own login — her own password, her own
email — that's a second invitation, and the admin decides which they want.)
- **The kids toggle is a preset, not a form.** Flipping `is_child` on reveals
a rating ceiling (default PG) and the library picker. Off, they stay hidden.
A grandparent profile is just a plain profile — no ceiling, no extra fields.
- **PIN guidance over PIN plumbing.** The common mistake is pinning the kid's
profile; the useful move is pinning the adults' so the kids can't wander in.
One line of copy under the toggle: "Usually for the parents' profiles, not
the kids' — it keeps the kids out of yours."
- **"Just me for now" is a first-class exit.** It creates nothing, and the
tour later includes a household `feature_card` reminding them profiles
exist. No nagging.
- **Restriction composition is unchanged.** A profile's rating ceiling and
library restrictions layer under the account's invitation-bound access via
the existing strictest-layer-wins resolver. The invitee cannot grant a
profile more than the account received.
- **Taste-seed stays per-profile.** Each created profile gets its own
taste-seed state, and the picker already filters by the active profile's
rating ceiling — Emma's picker shows kid-appropriate titles.
The invitation's `create_profile` flag becomes moot when this screen exists on
a surface: the first tile ("You") *is* the default profile, pre-named from the
email's local part and renameable inline. The flag stays in the schema for
headless accepts (a client too old to render the household step still gets a
usable account).
---
# Part 2 — Server-driven onboarding
## Why the server owns it
Three clients, three release cadences, and one of them has a multi-day review
queue. If the tour lives in client code:
- Adding a stop for a new feature means three PRs and waiting on Apple.
- A server with requests disabled still ships a requests stop, so the tour
advertises a 404.
- Fixing an awkward sentence is a store release.
With a server manifest, the tour is filtered per-server and per-profile before
it reaches the client, and copy changes are a server deploy.
## Contract
```
GET /api/v1/onboarding/flow?surface=web|phone|tv
```
Returns an ordered step list for **this server** and **this profile**, with
disabled features already filtered out:
```json
{
"version": 1,
"tour_id": "core-2026-07",
"steps": [
{
"id": "welcome",
"kind": "welcome",
"title": "Silo isn't quite like the others",
"body": "You've probably used Plex or Jellyfin...",
"illustration": "welcome"
},
{
"id": "playback-quality",
"kind": "setting_choice",
"title": "Pick a quality ceiling now, change it anywhere",
"body": "Silo won't burn your data plan guessing...",
"setting": {
"target": "profile_field",
"key": "quality_preference",
"control": "segmented",
"options": [
{"value": "auto", "label": "Auto"},
{"value": "1080p", "label": "1080p"},
{"value": "4k", "label": "4K"}
],
"default": "auto"
}
},
{
"id": "watch-together",
"kind": "feature_card",
"title": "Same movie, different couches",
"body": "Start a room, send the link...",
"illustration": "watch-together",
"action": {"label": "Show me", "route": "/rooms/join"}
},
{
"id": "handoff-taste",
"kind": "handoff",
"title": "Pick a few favorites",
"route": "/taste-seed"
}
]
}
```
### Step kinds
| `kind` | Renders | Notes |
| --- | --- | --- |
| `welcome` | Framing card | Always first. |
| `feature_card` | Title, body, illustration key, optional route action | The bulk of the tour. |
| `setting_choice` | A control that writes a real setting | See below. |
| `spotlight` | Highlight of a named UI anchor | Web/tablet only; phone and TV skip. |
| `handoff` | Ends the tour by routing somewhere | Hands off to `/taste-seed`. |
**Unknown kinds are skipped silently.** This is the whole point of the
versioned contract: the server may add a kind, and an older client drops it
rather than crashing or rendering a blank card. Clients send their surface;
the server omits steps that surface cannot render (spotlights on TV, text
entry on TV).
**Illustrations ship with the client**, keyed by a string. The server never
sends image URLs — assets stay native, sized correctly per platform, and work
offline.
### `setting_choice` writes real settings
The tour is not a slideshow that ends with "now go configure it yourself" — by
the last step the account is genuinely set up. It writes through the *existing*
APIs, so there is no new persistence and no risk of the tour and the settings
screens disagreeing.
Silo has three distinct write targets, and the step must say which one it
means. This matters: playback quality is **not** a settings key — it is a
column on the profile, written via `PUT /profiles/{id}` (see
`saveProfileField` in `web/src/pages/settings/PlaybackSettings.tsx:114`).
Collapsing all three into one "key" field would produce a contract that cannot
express the most important step in the tour.
| `target` | Written via | Used for |
| --- | --- | --- |
| `profile_field` | `PUT /api/v1/profiles/{id}` | `quality_preference`, `subtitle_language`, `subtitle_mode`, `auto_skip_intro`, `auto_skip_credits`, `auto_skip_recap` |
| `setting` | `PUT /api/v1/settings/{key}` | `playback.auto_play_next`, `subtitle_appearance`, overlay and home keys |
| `device_setting` | `PUT /api/v1/settings/device/{key}` | Anything that should differ per device (a quality ceiling on cellular) |
The server only emits a `setting_choice` the current profile may actually
write, so a child profile never gets a step it would be refused on.
### Progress
```
GET /api/v1/onboarding/state → {"tour_id": "...", "completed_at": ..., "skipped_at": ..., "last_step": "..."}
POST /api/v1/onboarding/progress → {"tour_id": "...", "last_step": "...", "completed": bool, "skipped": bool}
```
State is stored **per profile, server-side**, in the existing per-user SQLite
store (`internal/userdb`) — no new Postgres table, and no join from Postgres to
profile storage (profiles may live in per-user SQLite, which is why the
notification tables denormalize `user_id` rather than joining).
Note that `user_settings` is keyed by `key` alone and is therefore
**account-wide, not per-profile** (`internal/userdb/schema.go:185`). Storing
tour state there would mean one profile finishing the tour silences it for the
whole household. Two options, in preference order:
1. **A dedicated `profile_onboarding` table** in the userdb schema — keyed
`(profile_id, tour_id)` with `last_step`, `completed_at`, `skipped_at`.
Explicit, queryable, and the right shape if contextual follow-up tours
(multiple `tour_id`s) happen later. Requires a userdb schema version bump
(`internal/userdb/migrate.go`).
2. A profile-prefixed key in `user_settings` (`onboarding.<profile_id>.state`).
No schema change, but it abuses a flat key space and makes "show me every
profile that skipped" a `LIKE` scan.
**Recommendation: option 1.** The userdb schema already carries per-profile
tables (`subtitle_preferences`, `series_playback_preferences`), so this follows
the established pattern rather than working around it.
Two consequences worth stating plainly:
- Finishing the tour on the web means the phone does not ask again.
- Skipping is honored everywhere, permanently, until the user replays it from
Settings → Personalize.
The current taste-seed dismissal is a **localStorage** flag
(`web/src/lib/tasteSeed.ts`), which is why it re-prompts on every new browser
and never syncs to the apps. The tour deliberately does not repeat that
mistake; taste-seed's own flag can migrate to the same mechanism later.
### Gating
Steps are filtered against what the server actually has on:
| Step | Gated on |
| --- | --- |
| Requests | `requests_enabled` (`internal/requests`) |
| Watch together | watch-together enabled |
| Notifications | `mail.Sender.Enabled()` or push configured |
| Calendar | any library with scheduled content |
| Recommendations | recommendations enabled |
| Subtitles / quality / overlays | always (core playback) |
A server with nothing but a movie library gets a short, honest tour rather
than a tour of things it does not do.
## Reach
The tour is **not invite-only**. Anyone whose profile has no completion record
gets it: existing accounts, code-signup accounts, and additional profiles on
an existing account. The invitation just makes the entrance nicer. The
`show_tour` flag on an invitation only suppresses it for the deliberate case
(re-inviting someone who already knows the product).
---
## Security notes
- **Tokens at rest are hashed.** A DB dump yields no usable links.
- **Enumeration.** `GET /invitations/{token}` returns identical 404s for
unknown, expired, revoked, and accepted tokens. The claim screen distinguishes
them only via the accept path's error codes, which are rate-limited. 32 bytes
of entropy makes guessing irrelevant.
- **Privilege ceiling.** An admin cannot mint an invitation granting more than
they hold. Role `admin` on an invitation requires the inviter be an admin —
enforced at the handler, not just hidden in the UI.
- **Access composition is unchanged.** Pre-bound `library_ids` and
`access_group_id` feed the existing restriction-only resolver
(`docs/superpowers/specs/2026-07-02-access-groups-design.md`): the strictest
layer still wins. An invitation is a convenience for setting the initial
values, never a bypass.
- **Mail as an oracle.** Invitation sending is admin-only, so it is not a
spray vector the way unauthenticated verification sends would be. Resend
inherits the same admin gate.
- **Note field** is admin-authored and rendered escaped in HTML mail and raw in
the text part — same handling as every other user-supplied value in
`internal/mail`.
## Risks and tradeoffs
**Email-as-username changes the login lookup.** Mitigated by making it a
fallback (username first, email only on miss and only when the input parses as
an address), so existing behavior is untouched. Worth a test that asserts a
user whose *username* is `a@b.com` and a different user whose *email* is
`a@b.com` cannot both exist — the unique constraints already prevent it, but
the test documents why the fallback is unambiguous.
**A server-driven tour couples client releases to a contract.** Mitigated by
"unknown kinds are skipped" and an explicit `version`. The failure mode of a
client that is too old is a shorter tour, not a broken one.
**Apple universal links need server-side association hosting.** The `silo://`
custom scheme works immediately; universal links are a follow-up that improves
the experience but does not gate the feature.
**Scope.** This is a three-repo change. The server plus web is independently
shippable and useful on its own; the clients can follow without the server work
being wasted in the meantime.
## Future work
- Password reset reusing the same token shape and mail plumbing.
- Self-serve "invite a friend" for household primaries, bounded by an
admin-set quota — the table already carries `invited_by`.
- Migrating taste-seed's localStorage dismissal to server-side onboarding
state.
- Contextual follow-ups ("you've watched 5 things — did you know about
watchlists?") reusing the same manifest with a different `tour_id`.
## Open questions
1. **Invitation expiry** — 7 days assumed. Admin-configurable server setting,
or fixed?
2. **Tour replay entry point** — Settings → Personalize is assumed. Confirm
that is the right home rather than a Help menu.
3. **`tour_id` versioning** — when the tour changes materially, should users
who completed `core-2026-07` be re-prompted for `core-2027-01`, or does
completion mean completion forever?
@@ -0,0 +1,86 @@
# Transcode Resolution Clamp Design
## Problem
When video encoding is requested for a 2160p file while 4K transcoding is
disabled, the playback handler selects a lower-resolution alternate file.
Today it continues using the target resolution supplied for the original file.
That lets a 1080p alternate reach FFmpeg with a 2160p target, causing an
unnecessary upscale and making the persisted playback activity claim that
2160p was delivered.
Production logs confirmed this exact path with a 1080p H.264 alternate and a
QSV `scale_vaapi` filter targeting 2160 lines.
## Decision
The server owns the output-resolution invariant. After all alternate-file and
probe selection has completed, and before planning or starting any local or
remote transport, it will normalize an encoded-video target so it cannot
exceed the effective source file's resolution.
The normalization rules are:
- Do not apply the new clamp to video-copy requests; preserve their existing
downstream recipe normalization.
- Preserve an empty or unrecognized requested resolution unchanged.
- Preserve an empty or unrecognized effective source resolution unchanged.
- Preserve targets at or below the effective source resolution.
- Clamp a recognized target above a recognized effective source resolution to
the effective source resolution.
- Do not alter target bitrate. The observed 10 Mbps value is already a valid
1080p-high encoding target and is not the demonstrated defect.
Recognized resolutions are the same tiers already supported by the transcode
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
## Architecture and Data Flow
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.
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`;
- playback-session replacement and activity reporting;
- later transport reconstruction and restart recipes.
This boundary is preferred over a web-only correction because every client can
call the endpoint, and over an FFmpeg-only correction because session state and
remote transcode requests must remain truthful.
## Compatibility and Error Handling
The change is additive enforcement inside the existing `/api/v1` endpoint. It
does not change request or response shapes, status codes, codec selection,
alternate-file selection, or copy-video behavior. Unsupported resolution
strings retain their existing behavior rather than being silently reinterpreted.
No server settings, migrations, client changes, or production deployment
changes are part of this work.
## Verification
Tests will establish:
1. A 2160p encode request switched to a 1080p alternate reaches the remote
transcode node as 1080p and persists 1080p in the playback session.
2. The same normalized request reaches the local transcode option boundary.
3. Targets already below the source remain unchanged.
4. Copy-video and unknown-resolution requests remain unchanged.
5. Existing playback-handler and playback-package suites remain green.
The regression must fail against unmodified main before the implementation is
added.
## Security and Operational Impact
The server currently permits an authenticated user with transcoding permission
to amplify compute and bandwidth by requesting a recognized output larger than
the effective source. The clamp removes that unnecessary amplification while
retaining all authorized transcoding behavior.
Rollback is a single code commit. No data rollback is required.
+4 -4
View File
@@ -12,7 +12,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2
github.com/mattn/go-sqlite3 v1.14.34
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/client_model v0.6.2
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
@@ -33,6 +33,7 @@ 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
@@ -78,9 +79,8 @@ require (
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.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.12.0
github.com/Silo-Server/silo-plugin-sdk v0.13.2
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
+6 -4
View File
@@ -4,8 +4,10 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.13.1 h1:3vMaV+aPT/vu47CPk/f3nODsjXCp1OseI0QIoLnOvOM=
github.com/Silo-Server/silo-plugin-sdk v0.13.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.13.2 h1:w7U0mmljVPauKfzRLNKusuiYFpuoEhuCSX4Hp3s9eRw=
github.com/Silo-Server/silo-plugin-sdk v0.13.2/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=
@@ -238,8 +240,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
+34
View File
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,152 @@
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)
}
}
}
+30 -13
View File
@@ -10,8 +10,11 @@ import (
"github.com/Silo-Server/silo-server/internal/userstore"
)
// settingKeyDisabledLibraryIDs is the user-settings key that stores a JSON
// array of library IDs the user has chosen to hide.
// 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.
const settingKeyDisabledLibraryIDs = "disabled_library_ids"
// UserRepository loads account-level access settings.
@@ -72,6 +75,7 @@ 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 {
@@ -83,7 +87,8 @@ 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 = profile.PreferredMetadataLanguage
scope.PreferredMetadataLanguage = preferences.PreferredMetadataLanguage
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
if err != nil {
@@ -92,8 +97,8 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.ProfileVerified = verified
}
// Apply user-level disabled library IDs setting.
disabled := DisabledLibraryIDs(ctx, store)
// Apply the profile's disabled library IDs setting.
disabled := preferences.DisabledLibraryIDs
if len(disabled) > 0 {
if scope.AllowedLibraryIDs != nil {
// Restricted user: subtract disabled IDs from the allowed set.
@@ -139,17 +144,29 @@ func VerifyProfileForRequest(
return profileVerified, 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
}
// 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([]byte(raw), &ids); err != nil {
if err := json.Unmarshal(raw, &ids); err != nil {
return nil
}
// Filter out invalid values.
n := 0
for _, id := range ids {
if id > 0 {
+210 -1
View File
@@ -2,11 +2,14 @@ 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"
)
@@ -39,6 +42,10 @@ 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") }
@@ -216,7 +223,19 @@ 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) 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) 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) {
@@ -271,6 +290,42 @@ 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(
@@ -406,6 +461,93 @@ 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}},
@@ -427,6 +569,73 @@ 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,6 +12,9 @@ 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
@@ -0,0 +1,145 @@
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))
}
+27 -5
View File
@@ -40,13 +40,28 @@ type deleteLibraryExecutor interface {
Execute(ctx context.Context, req DeleteLibraryRequest, progress func(current, total int, message string)) (*DeleteLibraryResult, error)
}
type LibraryDeleteExecutor struct {
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
// 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
}
func NewLibraryDeleteExecutor(folderRepo *catalog.FolderRepository, sectionRepo *sections.Repository) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{folderRepo: folderRepo, sectionRepo: sectionRepo}
type LibraryDeleteExecutor struct {
folderRepo *catalog.FolderRepository
sectionRepo *sections.Repository
settingsCleaner LibrarySettingsCleaner
}
func NewLibraryDeleteExecutor(
folderRepo *catalog.FolderRepository,
sectionRepo *sections.Repository,
settingsCleaner LibrarySettingsCleaner,
) *LibraryDeleteExecutor {
return &LibraryDeleteExecutor{
folderRepo: folderRepo,
sectionRepo: sectionRepo,
settingsCleaner: settingsCleaner,
}
}
func (e *LibraryDeleteExecutor) Execute(
@@ -82,6 +97,13 @@ 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")
}
+28
View File
@@ -118,11 +118,39 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
COALESCE(mi.tvdb_id, '') <> ''
OR COALESCE(mi.imdb_id, '') <> ''
)
AND NOT EXISTS (
SELECT 1
FROM stale_media_ids rejected_tmdb
WHERE rejected_tmdb.content_id = mi.content_id
AND LOWER(TRIM(rejected_tmdb.provider)) = 'tmdb'
AND TRIM(rejected_tmdb.provider_id) <> ''
)
)
OR EXISTS (
SELECT 1
FROM stale_media_ids smi
WHERE smi.content_id = mi.content_id
AND LOWER(TRIM(smi.provider)) IN ('tmdb', 'tvdb', 'imdb')
AND TRIM(smi.provider_id) <> ''
AND (
LOWER(TRIM(COALESCE(mi.status, ''))) <> 'matched'
OR (
LOWER(TRIM(smi.provider)) = 'tmdb'
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tmdb_id, ''))
)
OR (
LOWER(TRIM(smi.provider)) = 'tvdb'
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tvdb_id, ''))
)
OR (
LOWER(TRIM(smi.provider)) = 'imdb'
AND LOWER(TRIM(smi.provider_id)) = LOWER(TRIM(COALESCE(mi.imdb_id, '')))
)
OR LOWER(TRIM(mi.content_id)) =
'movie-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
OR LOWER(TRIM(mi.content_id)) =
'series-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
)
)
OR (
COALESCE(mi.default_metadata_language, '') <> ''
@@ -91,3 +91,102 @@ func TestQuickRefreshListsLanguageMismatchedItems(t *testing.T) {
t.Errorf("quick refresh must not include complete item whose stamped language matches the library language")
}
}
func TestQuickRefreshIgnoresHistoricalSecondaryStaleIDs(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
historicalID := "movie-tmdb-" + suffix
actionableProviderID := suffix + "1"
actionableID := "movie-tmdb-" + actionableProviderID
rejectedSiblingID := "series-tvdb-" + suffix
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled, metadata_language)
VALUES ('movies', 'Stale Refresh Test', true, 'en')
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{historicalID, actionableID, rejectedSiblingID})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, row := range []struct {
contentID, tmdbID, staleProvider, staleProviderID string
}{
{historicalID, suffix, "tvdb", "999999"},
{actionableID, actionableProviderID, "tmdb", actionableProviderID},
} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tmdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, row.contentID, row.tmdbID); err != nil {
t.Fatalf("seed media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, row.contentID, folderID); err != nil {
t.Fatalf("link media item %s: %v", row.contentID, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, $2, $3)
`, row.contentID, row.staleProvider, row.staleProviderID); err != nil {
t.Fatalf("seed stale id for %s: %v", row.contentID, err)
}
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tvdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'series', 'Complete TVDB Item', 'matched', '{}'::text[], $2,
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, rejectedSiblingID, suffix); err != nil {
t.Fatalf("seed TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
`, rejectedSiblingID, folderID); err != nil {
t.Fatalf("link TVDB-only media item: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO stale_media_ids (content_id, provider, provider_id)
VALUES ($1, 'tmdb', $2)
`, rejectedSiblingID, suffix+"2"); err != nil {
t.Fatalf("seed rejected TMDB sibling: %v", err)
}
items, err := NewPGLibraryRefreshItemLister(pool).ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
if err != nil {
t.Fatalf("ListLibraryItems: %v", err)
}
listed := make(map[string]bool, len(items))
for _, item := range items {
listed[item.ContentID] = true
}
if listed[historicalID] {
t.Fatal("historical secondary stale ID must not keep a complete item in quick refresh")
}
if !listed[actionableID] {
t.Fatal("current canonical stale ID must remain in quick refresh")
}
if listed[rejectedSiblingID] {
t.Fatal("a rejected TMDB sibling must not keep provider-ID enrichment debt active")
}
}
+35 -4
View File
@@ -99,20 +99,51 @@ 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 c != nil && c.Metadata != nil {
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
if name := scanSourceMetadataString(c, "display_name"); name != "" {
return name
}
switch {
case pluginID != "" && c != nil && c.ID != "":
+58 -366
View File
@@ -37,6 +37,8 @@ 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"
)
@@ -1355,10 +1357,6 @@ 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"`
@@ -1413,345 +1411,6 @@ 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 {
@@ -1779,6 +1438,10 @@ 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)
@@ -1796,6 +1459,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
user.Username,
user.Email,
entries,
canonicalValues,
devices,
profileNames,
)
@@ -1862,6 +1526,11 @@ 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")
@@ -1885,11 +1554,18 @@ 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,
)
@@ -1922,25 +1598,6 @@ 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)),
@@ -1966,6 +1623,7 @@ func buildAdminDeviceSummaries(
username string,
email string,
entries []userstore.DeviceSettingEntry,
canonicalValues []userstore.SettingValue,
registeredDevices []userstore.DeviceEntry,
profileNames map[string]string,
) []adminDeviceSummaryResponse {
@@ -2067,12 +1725,36 @@ func buildAdminDeviceSummaries(
if current == nil {
continue
}
if profileID != "" && entry.Key != "" {
current.keys[profileID+":"+entry.Key] = struct{}{}
key := canonicalAdminDeviceSettingKey(entry.Key)
if profileID != "" && key != "" {
current.keys[profileID+":"+key] = struct{}{}
}
profile := ensureProfile(current, profileID, entry.UpdatedAt)
if profile != nil && entry.Key != "" {
profile.keys[entry.Key] = struct{}{}
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{}{}
}
}
@@ -2105,6 +1787,13 @@ 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 {
@@ -2586,7 +2275,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
req.Value = normalized
case "ai.base_url", "ai.chat_model", "ai.asr_model":
req.Value = strings.TrimSpace(req.Value)
case "ai.asr_base_url":
req.Value = strings.TrimSpace(req.Value)
if llm.IsChatOnlyGateway(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
+211
View File
@@ -0,0 +1,211 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/invitations"
"github.com/Silo-Server/silo-server/internal/models"
)
// AdminInvitationHandler handles admin endpoints for emailed invitations.
type AdminInvitationHandler struct {
service *invitations.Service
}
// NewAdminInvitationHandler creates a new AdminInvitationHandler.
func NewAdminInvitationHandler(service *invitations.Service) *AdminInvitationHandler {
return &AdminInvitationHandler{service: service}
}
// --- Request/Response types ---
type createInvitationRequest struct {
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id"`
LibraryIDs []int `json:"library_ids"`
CreateProfile *bool `json:"create_profile"`
ShowTour *bool `json:"show_tour"`
Note string `json:"note"`
}
type invitationResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id,omitempty"`
LibraryIDs []int `json:"library_ids,omitempty"`
CreateProfile bool `json:"create_profile"`
ShowTour bool `json:"show_tour"`
Note string `json:"note,omitempty"`
InvitedBy int64 `json:"invited_by"`
InvitedByName string `json:"invited_by_name,omitempty"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *string `json:"accepted_at,omitempty"`
AcceptedUser *int64 `json:"accepted_user_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type sendInvitationResponse struct {
Invitation invitationResponse `json:"invitation"`
EmailSent bool `json:"email_sent"`
// ClaimURL embeds the single-use token, so this response is the only
// chance to read it — the server keeps just the hash. Returned even when
// the email sent, so the admin can also deliver the link directly.
ClaimURL string `json:"claim_url,omitempty"`
}
func toInvitationResponse(inv *models.Invitation, now time.Time) invitationResponse {
resp := invitationResponse{
ID: inv.ID,
Email: inv.Email,
Role: inv.Role,
AccessGroupID: inv.AccessGroupID,
LibraryIDs: inv.LibraryIDs,
CreateProfile: inv.CreateProfile,
ShowTour: inv.ShowTour,
Note: inv.Note,
InvitedBy: inv.InvitedBy,
InvitedByName: inv.InvitedByName,
Status: inv.Status(now),
ExpiresAt: inv.ExpiresAt,
AcceptedUser: inv.AcceptedUserID,
CreatedAt: inv.CreatedAt,
}
if inv.AcceptedAt != nil {
accepted := inv.AcceptedAt.Format(time.RFC3339)
resp.AcceptedAt = &accepted
}
return resp
}
// HandleListInvitations handles GET /admin/invitations.
func (h *AdminInvitationHandler) HandleListInvitations(w http.ResponseWriter, r *http.Request) {
list, err := h.service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list invitations")
return
}
now := time.Now()
resp := make([]invitationResponse, 0, len(list))
for _, inv := range list {
resp = append(resp, toInvitationResponse(inv, now))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateInvitation handles POST /admin/invitations.
func (h *AdminInvitationHandler) HandleCreateInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req createInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
createProfile := true
if req.CreateProfile != nil {
createProfile = *req.CreateProfile
}
showTour := true
if req.ShowTour != nil {
showTour = *req.ShowTour
}
result, err := h.service.Send(r.Context(), invitations.SendInput{
Email: req.Email,
Role: req.Role,
AccessGroupID: req.AccessGroupID,
LibraryIDs: req.LibraryIDs,
CreateProfile: createProfile,
ShowTour: showTour,
Note: req.Note,
InvitedBy: int64(claims.UserID),
})
if err != nil {
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusCreated, buildSendResponse(result))
}
// HandleResendInvitation handles POST /admin/invitations/{id}/resend.
func (h *AdminInvitationHandler) HandleResendInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
result, err := h.service.Resend(r.Context(), id, int64(claims.UserID))
if err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusOK, buildSendResponse(result))
}
// HandleRevokeInvitation handles DELETE /admin/invitations/{id}.
func (h *AdminInvitationHandler) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
if err := h.service.Revoke(r.Context(), id); err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke invitation")
return
}
w.WriteHeader(http.StatusNoContent)
}
func buildSendResponse(result *invitations.SendResult) sendInvitationResponse {
resp := sendInvitationResponse{
Invitation: toInvitationResponse(result.Invitation, time.Now()),
EmailSent: result.EmailSent,
ClaimURL: result.ClaimURL,
}
return resp
}
func writeInvitationSendError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invitations.ErrInvalidEmail):
writeError(w, http.StatusBadRequest, "invalid_email", "Invalid email address")
case errors.Is(err, invitations.ErrEmailTaken):
writeError(w, http.StatusConflict, "email_taken", "An account with this email already exists")
case errors.Is(err, invitations.ErrRoleNotAllowed):
writeError(w, http.StatusForbidden, "role_not_allowed", "You may not grant this role")
case errors.Is(err, invitations.ErrNoLinkBase):
writeError(w, http.StatusConflict, "no_link_base",
"Configure notifications.email.external_url (or a server public URL) so invitation links can be built")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send invitation")
}
}
+180 -1
View File
@@ -3,16 +3,19 @@ package handlers
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
@@ -51,6 +54,11 @@ type mdblistSettingsCheckClient interface {
Check(ctx context.Context) error
}
type aiSettingsCheckClient interface {
Chat(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
type redisSettingsCheckAdapter struct {
client *redis.Client
}
@@ -88,6 +96,10 @@ var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsChec
return mdblist.NewClient(apiKey, nil)
}
var newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
return llm.NewClient(cfg)
}
func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
@@ -109,7 +121,7 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
req.Values = map[string]string{}
}
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req)
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), kind, req)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return
@@ -131,6 +143,10 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
response = checkRedisConnection(r.Context(), cfg)
case "recommendations_embedding":
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
case "ai_chat":
response = checkAIChatConnection(r.Context(), cfg)
case "ai_transcription":
response = checkAITranscriptionConnection(r.Context(), cfg)
case "meilisearch":
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
case "mdblist":
@@ -155,6 +171,105 @@ func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionC
return connectionCheckResponse{Success: true, Message: "MDBList API key verified."}
}
func aiClientConfig(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: strings.TrimSpace(cfg.AI.BaseURL),
APIKey: cfg.AI.APIKey,
ChatModel: strings.TrimSpace(cfg.AI.ChatModel),
ASRBaseURL: strings.TrimSpace(cfg.AI.ASRBaseURL),
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: strings.TrimSpace(cfg.AI.ASRModel),
}
}
func checkAIChatConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return connectionCheckResponse{Success: false, Message: "Text AI base URL is required."}
}
if strings.TrimSpace(cfg.AI.ChatModel) == "" {
return connectionCheckResponse{Success: false, Message: "Chat model is required."}
}
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := client.Chat(checkCtx, []llm.Message{
{Role: "system", Content: "Return a JSON object with status set to ok."},
{Role: "user", Content: "Check this Silo text translation connection."},
}, true); err != nil {
return connectionCheckResponse{
Success: false,
Message: fmt.Sprintf("Text AI connection check failed: %v", err),
}
}
return connectionCheckResponse{Success: true, Message: "Text AI connection successful."}
}
func checkAITranscriptionConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
effectiveBaseURL := strings.TrimSpace(cfg.AI.ASRBaseURL)
if effectiveBaseURL == "" {
effectiveBaseURL = strings.TrimSpace(cfg.AI.BaseURL)
}
if effectiveBaseURL == "" {
return connectionCheckResponse{
Success: false,
Message: "Speech-to-text base URL is required.",
}
}
if strings.TrimSpace(cfg.AI.ASRModel) == "" {
return connectionCheckResponse{
Success: false,
Message: "Transcription model is required.",
}
}
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
checkCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
defer cancel()
if _, err := client.Transcribe(checkCtx, llm.TranscribeRequest{
Filename: "silo-connection-check.wav",
Audio: silenceWAV(),
Timeout: 30 * time.Second,
}); err != nil {
return connectionCheckResponse{
Success: false,
Message: fmt.Sprintf("Speech-to-text connection check failed: %v", err),
}
}
return connectionCheckResponse{
Success: true,
Message: "Speech-to-text connection successful.",
}
}
// silenceWAV returns 250 ms of 16 kHz mono PCM. It is long enough for
// transcription providers to parse while keeping connection checks cheap.
func silenceWAV() []byte {
const (
sampleRate = 16_000
bitsPerSample = 16
sampleCount = sampleRate / 4
dataSize = sampleCount * bitsPerSample / 8
)
wav := make([]byte, 44+dataSize)
copy(wav[0:4], "RIFF")
binary.LittleEndian.PutUint32(wav[4:8], uint32(36+dataSize))
copy(wav[8:12], "WAVE")
copy(wav[12:16], "fmt ")
binary.LittleEndian.PutUint32(wav[16:20], 16)
binary.LittleEndian.PutUint16(wav[20:22], 1)
binary.LittleEndian.PutUint16(wav[22:24], 1)
binary.LittleEndian.PutUint32(wav[24:28], sampleRate)
binary.LittleEndian.PutUint32(wav[28:32], sampleRate*bitsPerSample/8)
binary.LittleEndian.PutUint16(wav[32:34], bitsPerSample/8)
binary.LittleEndian.PutUint16(wav[34:36], bitsPerSample)
copy(wav[36:40], "data")
binary.LittleEndian.PutUint32(wav[40:44], dataSize)
return wav
}
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
if err != nil {
@@ -173,6 +288,7 @@ func checkMeilisearchConnection(ctx context.Context, settings map[string]string)
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
ctx context.Context,
kind string,
req adminSettingsConnectionCheckRequest,
) (map[string]string, error) {
settings, err := h.SettingsRepo.GetAll(ctx)
@@ -190,13 +306,76 @@ func (h *AdminHandler) effectiveSettingsForConnectionCheck(
}
merged[key] = value
}
var storedAIConfig *config.Config
if kind == "ai_chat" || kind == "ai_transcription" {
storedAIConfig, err = config.LoadFromDB(merged)
if err != nil {
return nil, err
}
}
for _, key := range req.DirtyKeys {
merged[key] = req.Values[key]
}
if storedAIConfig != nil {
draftAIConfig, loadErr := config.LoadFromDB(merged)
if loadErr != nil {
return nil, loadErr
}
protectAIConnectionCheckSecrets(kind, req, storedAIConfig, draftAIConfig, merged)
}
return merged, nil
}
func protectAIConnectionCheckSecrets(
kind string,
req adminSettingsConnectionCheckRequest,
storedCfg *config.Config,
draftCfg *config.Config,
settings map[string]string,
) {
storedEndpoint := storedCfg.AI.BaseURL
draftEndpoint := draftCfg.AI.BaseURL
if kind == "ai_transcription" {
if strings.TrimSpace(storedCfg.AI.ASRBaseURL) != "" {
storedEndpoint = storedCfg.AI.ASRBaseURL
}
if strings.TrimSpace(draftCfg.AI.ASRBaseURL) != "" {
draftEndpoint = draftCfg.AI.ASRBaseURL
}
}
if endpointAuthority(storedEndpoint) == endpointAuthority(draftEndpoint) {
return
}
if kind == "ai_transcription" && !hasExplicitDraftSecret(req, "ai.asr_api_key") {
settings["ai.asr_api_key"] = ""
}
if !hasExplicitDraftSecret(req, "ai.api_key") {
settings["ai.api_key"] = ""
settings["subtitle_ai.api_key"] = ""
}
}
func hasExplicitDraftSecret(req adminSettingsConnectionCheckRequest, key string) bool {
for _, dirtyKey := range req.DirtyKeys {
if dirtyKey == key {
return strings.TrimSpace(req.Values[key]) != ""
}
}
return false
}
func endpointAuthority(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.ToLower(trimmed)
}
return strings.ToLower(parsed.Scheme + "://" + parsed.Host)
}
func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" {
return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."}
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/diagnostics"
@@ -886,6 +887,239 @@ func (f *fakeEmbeddingsSettingsCheckClient) Embed(
return [][]float32{{0.1, 0.2}}, nil
}
type fakeAISettingsCheckClient struct {
chat func(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
transcribe func(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
}
func (f *fakeAISettingsCheckClient) Chat(
ctx context.Context,
messages []llm.Message,
jsonObject bool,
) (string, error) {
if f.chat != nil {
return f.chat(ctx, messages, jsonObject)
}
return `{"status":"ok"}`, nil
}
func (f *fakeAISettingsCheckClient) Transcribe(
ctx context.Context,
req llm.TranscribeRequest,
) (*llm.Transcription, error) {
if f.transcribe != nil {
return f.transcribe(ctx, req)
}
return &llm.Transcription{}, nil
}
func TestHandleCheckSettingsConnectionAIChatDoesNotSendStoredKeyToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var chatCalled bool
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
chat: func(_ context.Context, _ []llm.Message, jsonObject bool) (string, error) {
chatCalled = true
if !jsonObject {
t.Fatal("chat connection check did not request a JSON response")
}
return `{"status":"ok"}`, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://draft.example.test",
"ai.chat_model": "draft-model",
"ai.api_key": "",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success || !chatCalled {
t.Fatalf("response = %+v, chatCalled = %v", response, chatCalled)
}
if captured.BaseURL != "https://draft.example.test" || captured.ChatModel != "draft-model" {
t.Fatalf("captured config = %+v, want draft endpoint and model", captured)
}
if captured.APIKey != "" {
t.Fatalf("captured API key = %q, want no stored secret for a changed endpoint", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAIChatReusesStoredKeyForSameEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://persisted.example.test/v1",
"ai.chat_model": "persisted-model",
"ai.api_key": "persisted-secret",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_chat",
map[string]any{
"values": map[string]string{
"ai.base_url": "https://persisted.example.test/other-path",
"ai.chat_model": "draft-model",
},
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.APIKey != "persisted-secret" {
t.Fatalf("captured API key = %q, want stored secret for unchanged authority", captured.APIKey)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionUsesDedicatedASR(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
var request llm.TranscribeRequest
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{
transcribe: func(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) {
request = req
return &llm.Transcription{}, nil
},
}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{"values": map[string]string{}, "dirty_keys": []string{}},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var response connectionCheckResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("Decode() returned error: %v", err)
}
if !response.Success {
t.Fatalf("response.Success = false, message = %q", response.Message)
}
if captured.ASRBaseURL != "https://whisper.example.test" ||
captured.ASRAPIKey != "asr-secret" ||
captured.ASRModel != "whisper-model" {
t.Fatalf("captured ASR config = %+v", captured)
}
if len(request.Audio) == 0 || request.Filename == "" || request.Timeout <= 0 {
t.Fatalf("transcription probe request = %+v, want bounded WAV probe", request)
}
}
func TestHandleCheckSettingsConnectionAITranscriptionDoesNotSendStoredKeysToDraftEndpoint(t *testing.T) {
originalFactory := newAdminAISettingsCheckClient
t.Cleanup(func() {
newAdminAISettingsCheckClient = originalFactory
})
var captured llm.Config
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
captured = cfg
return &fakeAISettingsCheckClient{}
}
handler := &AdminHandler{
SettingsRepo: &fakeServerSettingsStore{
values: map[string]string{
"ai.base_url": "https://chat.example.test",
"ai.api_key": "chat-secret",
"ai.chat_model": "chat-model",
"ai.asr_base_url": "https://stored-whisper.example.test",
"ai.asr_api_key": "asr-secret",
"ai.asr_model": "whisper-model",
},
},
}
rec := performSettingsCheckRequest(
t,
handler,
"/admin/settings/check/ai_transcription",
map[string]any{
"values": map[string]string{
"ai.asr_base_url": "https://draft-whisper.example.test",
},
"dirty_keys": []string{"ai.asr_base_url"},
},
)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if captured.ASRAPIKey != "" || captured.APIKey != "" {
t.Fatalf("captured config = %+v, want no stored ASR or inherited chat secret", captured)
}
}
func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) {
originalFactory := newAdminS3SettingsCheckClient
t.Cleanup(func() {
@@ -1238,6 +1472,44 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
}
}
func TestAdminUpdateSettingNormalizesAIEndpointAndModelValues(t *testing.T) {
for _, tc := range []struct {
key string
value string
want string
}{
{key: "ai.base_url", value: " https://text.example.test/v1 ", want: "https://text.example.test/v1"},
{key: "ai.chat_model", value: " chat-model ", want: "chat-model"},
{key: "ai.asr_base_url", value: " https://speech.example.test ", want: "https://speech.example.test"},
{key: "ai.asr_model", value: " whisper-model ", want: "whisper-model"},
} {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
body, err := json.Marshal(updateSettingRequest{Value: tc.value})
if err != nil {
t.Fatalf("Marshal() returned error: %v", err)
}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
bytes.NewReader(body),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.want {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
}
})
}
}
func TestAdminUpdatePolicyEditorEnabledValidation(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
+27 -4
View File
@@ -7,12 +7,18 @@ 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.
// 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.
type AudioPrefHandler struct {
storeProvider userstore.UserStoreProvider
EventsHub *evt.Hub
}
// NewAudioPrefHandler creates a new AudioPrefHandler.
@@ -100,9 +106,20 @@ 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 := store.SetAudioPreference(r.Context(), pref); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set audio preference")
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")
return
}
@@ -126,7 +143,13 @@ func (h *AudioPrefHandler) HandleDeleteAudioPref(w http.ResponseWriter, r *http.
return
}
if err := store.DeleteAudioPreference(r.Context(), profileID, seriesID); err != nil {
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 {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete audio preference")
return
}
+81
View File
@@ -0,0 +1,81 @@
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())
}
}
+3 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
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/clientip"
"github.com/Silo-Server/silo-server/internal/models"
@@ -344,7 +345,8 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, profileID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -0,0 +1,83 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
func TestPluginLaunchCookieCarriesValidatedProfile(t *testing.T) {
jwt := auth.NewJWTService("plugin-launch-test-secret", time.Minute, time.Hour)
accessToken, err := jwt.GenerateAccessToken(7, "user", "session-1")
if err != nil {
t.Fatalf("GenerateAccessToken: %v", err)
}
handler := NewAuthHandler(nil, jwt, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/plugin-launch", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req = req.WithContext(apimw.SetProfileID(req.Context(), "profile-1"))
rec := httptest.NewRecorder()
handler.HandlePluginLaunch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
response := rec.Result()
defer func() { _ = response.Body.Close() }()
var pluginCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.PluginAccessCookieName {
pluginCookie = cookie
break
}
}
if pluginCookie == nil {
t.Fatal("plugin access cookie was not set")
}
claims, err := jwt.ValidateToken(pluginCookie.Value)
if err != nil {
t.Fatalf("validating plugin cookie: %v", err)
}
if claims.ProfileID != "profile-1" || claims.TokenType != auth.TokenTypePluginAccess {
t.Fatalf("plugin claims = %#v", claims)
}
}
func TestPluginLaunchPreservesProfileOptionalCompatibility(t *testing.T) {
jwt := auth.NewJWTService("plugin-launch-test-secret", time.Minute, time.Hour)
accessToken, err := jwt.GenerateAccessToken(7, "user", "session-1")
if err != nil {
t.Fatalf("GenerateAccessToken: %v", err)
}
handler := NewAuthHandler(nil, jwt, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/plugin-launch", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
rec := httptest.NewRecorder()
handler.HandlePluginLaunch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
}
response := rec.Result()
defer func() { _ = response.Body.Close() }()
var pluginCookie *http.Cookie
for _, cookie := range response.Cookies() {
if cookie.Name == auth.PluginAccessCookieName {
pluginCookie = cookie
break
}
}
if pluginCookie == nil {
t.Fatal("plugin access cookie was not set")
}
claims, err := jwt.ValidateToken(pluginCookie.Value)
if err != nil {
t.Fatalf("validating plugin cookie: %v", err)
}
if claims.ProfileID != "" || claims.TokenType != auth.TokenTypePluginAccess {
t.Fatalf("plugin claims = %#v", claims)
}
}
+10 -3
View File
@@ -454,10 +454,15 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
// --- Available scan-source plugins (Add-source picker) ---
// autoscanScanSourcePluginResponse describes one creatable scan source. The
// descriptor is additive: clients that predate it keep reading the same three
// identity fields.
type autoscanScanSourcePluginResponse struct {
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Descriptor autoscan.ScanSourceDescriptor `json:"descriptor"`
}
// HandleListAvailableScanSources returns every installed scan_source capability
@@ -474,6 +479,8 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
PluginID: a.PluginID,
CapabilityID: a.CapabilityID,
DisplayName: a.DisplayName,
Description: a.Description,
Descriptor: a.Descriptor,
})
}
writeJSON(w, http.StatusOK, struct {
+6 -1
View File
@@ -228,6 +228,11 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
}
if len(seasons) > 0 {
if h.items.detailSvc != nil {
if localized, locErr := h.items.detailSvc.LocalizeSeasonModels(r.Context(), seasons, filter); locErr == nil && len(localized) == len(seasons) {
seasons = localized
}
}
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
@@ -245,7 +250,7 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
if hasProgressMap {
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
}
sr := h.items.toSeasonResponseFromEpisodes(r, id, s, episodes, userData)
sr := h.items.seasonResponseFromEpisodes(r, s, episodes, userData)
resp = append(resp, sr)
}
@@ -0,0 +1,103 @@
package handlers
import (
"context"
"net/http"
"github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
// compatConnectInfoKeys are the only settings this endpoint consults. They are
// read individually rather than via GetAll so an authenticated page view never
// loads or decrypts unrelated secrets, and so a decryption failure elsewhere in
// the settings table cannot mask a valid compat override.
var compatConnectInfoKeys = []string{
"jellyfin_compat.enabled",
"jellyfin_compat.public_url",
"jellyfin_compat.server_name",
}
// CompatConnectInfoHandler serves the account-facing view of the compatibility
// listeners: where to point a third-party client, and whether it is even on.
//
// The admin status endpoint covers the same ground for operators, but it also
// reports install paths and version provenance, so it stays admin-only. This
// handler exposes only the address a client would learn by connecting anyway.
type CompatConnectInfoHandler struct {
Config *config.Config
SettingsRepo ServerSettingsStore
Users UserRepository
}
func NewCompatConnectInfoHandler(
cfg *config.Config,
settings ServerSettingsStore,
users UserRepository,
) *CompatConnectInfoHandler {
return &CompatConnectInfoHandler{Config: cfg, SettingsRepo: settings, Users: users}
}
type compatConnectAccountInfo struct {
// PasswordLoginAvailable reports whether this account can authenticate
// with a password at all. Compat login is hardwired to the local provider,
// so SSO/plugin-provisioned accounts cannot sign in to a Jellyfin client
// no matter what they type.
PasswordLoginAvailable bool `json:"password_login_available"`
}
type compatConnectInfoResponse struct {
Jellyfin jellycompat.ConnectInfo `json:"jellyfin"`
Account compatConnectAccountInfo `json:"account"`
}
// HandleGetConnectInfo handles GET /compat/connect-info.
func (h *CompatConnectInfoHandler) HandleGetConnectInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeJSON(w, http.StatusOK, compatConnectInfoResponse{
Jellyfin: jellycompat.ConnectInfoForConfig(h.Config, h.compatSettings(ctx)),
Account: compatConnectAccountInfo{
PasswordLoginAvailable: h.passwordLoginAvailable(ctx),
},
})
}
// compatSettings loads the stored overrides this endpoint cares about. A
// missing store or a failed read is not fatal: the bootstrap config alone still
// describes a usable listener, so fall back to it rather than failing a purely
// informational read.
func (h *CompatConnectInfoHandler) compatSettings(ctx context.Context) map[string]string {
if h.SettingsRepo == nil {
return nil
}
settings := make(map[string]string, len(compatConnectInfoKeys))
for _, key := range compatConnectInfoKeys {
value, err := h.SettingsRepo.Get(ctx, key)
if err != nil {
continue
}
settings[key] = value
}
return settings
}
// passwordLoginAvailable reports whether the requesting account can use the
// password flow. It defaults to true when the user cannot be resolved: the page
// then shows its normal instructions rather than telling someone their working
// login is unsupported because of a lookup blip.
func (h *CompatConnectInfoHandler) passwordLoginAvailable(ctx context.Context) bool {
if h.Users == nil {
return true
}
userID := middleware.GetUserID(ctx)
if userID == 0 {
return true
}
user, err := h.Users.GetByID(ctx, userID)
if err != nil || user == nil {
return true
}
return user.LocalPasswordLoginEnabled
}
+326
View File
@@ -0,0 +1,326 @@
package handlers
import (
"log/slog"
"net/http"
"sort"
"strings"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/userstore"
)
// DeviceHandler serves a viewer's own device registry: the devices they watch
// on, and the settings those devices carry.
//
// This is the self-service twin of the admin device routes. The important
// difference is scoping. The store's ListDevices is account-wide by
// construction — "WHERE user_id" in Postgres, and no WHERE at all in the
// per-user SQLite backend, which keeps one database per account — so every
// read here filters to a profile in the handler. Returning the store's rows
// unfiltered would show every household member's devices to everyone.
type DeviceHandler struct {
storeProvider userstore.UserStoreProvider
// EventsHub, when set, receives a user_settings.changed event for every
// key a forget or clear removed. Nil (as in tests) skips publishing.
EventsHub *evt.Hub
// UserRepo and ProfileTokens enable ?scope=household for the household
// parent. Both nil means the whole-household read is simply unavailable —
// never that it is unguarded.
UserRepo userLookup
ProfileTokens *access.ProfileTokenService
}
func NewDeviceHandler(provider userstore.UserStoreProvider) *DeviceHandler {
return &DeviceHandler{storeProvider: provider}
}
type deviceResponse struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
LastSeenAt string `json:"last_seen_at"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
// IsCurrentDevice marks the device this request came from, so a client can
// say "you're here" without repeating the header-matching rule.
IsCurrentDevice bool `json:"is_current_device"`
// ChangedCount is how many settings this (profile, device) pair overrides.
// It is the one number a device list needs: everything else about a device
// is either its identity or its last-seen time.
ChangedCount int `json:"changed_count"`
}
type deviceListResponse struct {
Devices []deviceResponse `json:"devices"`
}
// HandleListDevices handles GET /devices.
func (h *DeviceHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
writeJSON(w, http.StatusOK, deviceListResponse{Devices: []deviceResponse{}})
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
// Household scope is opt-in and guarded. The default is this profile alone,
// so the ordinary screen stays private by construction rather than by a
// caller remembering to ask for less.
household := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("scope")), "household")
if household {
allowed, err := canManageHousehold(r, store, h.UserRepo, h.ProfileTokens)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden",
"Viewing the household's devices requires the primary profile or admin access")
return
}
}
devices, err := registry.ListDevices(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
// One pass over the stored values rather than a count query per device.
counts, err := deviceOverrideCounts(r, store)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read device settings")
return
}
profileNames, err := listProfileNamesByID(r.Context(), store)
if err != nil {
slog.WarnContext(r.Context(), "device list profile lookup failed",
"component", "api", "error", err)
profileNames = map[string]string{}
}
currentDeviceID := deviceMetadataFromRequest(r).DeviceID
resp := deviceListResponse{Devices: make([]deviceResponse, 0, len(devices))}
for _, device := range devices {
if !household && device.ProfileID != profileID {
continue
}
resp.Devices = append(resp.Devices, deviceResponse{
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DevicePlatform: device.DevicePlatform,
LastSeenAt: device.LastSeenAt,
ProfileID: device.ProfileID,
ProfileName: profileNames[device.ProfileID],
IsCurrentDevice: device.DeviceID == currentDeviceID,
ChangedCount: counts[deviceKey{profileID: device.ProfileID, deviceID: device.DeviceID}],
})
}
// ListDevices already orders by last_seen_at; keep that and make the tie
// break deterministic so a client's list does not reshuffle between reads.
sort.SliceStable(resp.Devices, func(i, j int) bool {
if resp.Devices[i].LastSeenAt != resp.Devices[j].LastSeenAt {
return resp.Devices[i].LastSeenAt > resp.Devices[j].LastSeenAt
}
return resp.Devices[i].DeviceID < resp.Devices[j].DeviceID
})
writeJSON(w, http.StatusOK, resp)
}
// HandleForgetDevice handles DELETE /devices/{device_id}: clear the device's
// settings and drop it from the registry.
func (h *DeviceHandler) HandleForgetDevice(w http.ResponseWriter, r *http.Request) {
h.removeDevice(w, r, true)
}
// HandleClearDeviceSettings handles DELETE /devices/{device_id}/settings:
// return the device to the profile's own values without forgetting it.
func (h *DeviceHandler) HandleClearDeviceSettings(w http.ResponseWriter, r *http.Request) {
h.removeDevice(w, r, false)
}
func (h *DeviceHandler) removeDevice(w http.ResponseWriter, r *http.Request, forget bool) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return
}
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A device id is required")
return
}
// The household parent may forget or clear a family member's device, on the
// same guard the settings routes use.
if named := strings.TrimSpace(r.URL.Query().Get("profile_id")); named != "" && named != profileID {
allowed, err := canManageHousehold(r, store, h.UserRepo, h.ProfileTokens)
if err != nil {
writeProfileManagementPermissionError(w, err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden",
"Managing another profile's devices requires the primary profile or admin access")
return
}
profile, err := store.GetProfile(r.Context(), named)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load profile")
return
}
if profile == nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
return
}
profileID = named
}
// A device this profile never registered is not this caller's to remove.
// 404 rather than 403: a 403 would confirm the id exists somewhere.
//
// "This profile has no trace of it" covers both a device that never existed
// and one already forgotten, which is what makes a repeated forget answer
// 204 rather than inventing a failure for work it already did. A device
// belonging to *another* profile is equally traceless here, so it takes the
// same path and learns nothing about that device's existence.
registry, isRegistry := store.(userstore.DeviceRegistry)
owned := false
if isRegistry {
exists, err := registry.DeviceExists(r.Context(), profileID, deviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up device")
return
}
owned = exists
}
keys, err := h.deviceSettingKeys(r, store, profileID, deviceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read device settings")
return
}
if !owned && len(keys) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
if _, err := store.DeleteSettingValuesForDevice(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear device settings")
return
}
// The legacy string-keyed device settings are a second generation of the
// same data; clearing one without the other would leave the device
// half-reset.
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear device settings")
return
}
if forget && isRegistry {
if err := registry.ForgetDevice(r.Context(), profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to forget device")
return
}
}
userID := apimw.GetUserID(r.Context())
for _, key := range keys {
publishUserSettingsEvent(r.Context(), h.EventsHub, userID, profileID,
key, string(settingscontract.ScopeProfileDevice))
}
action := "clear_device"
if forget {
action = "forget_device"
}
auditSettingsForOther(r.Context(), settingsAuditRecord{
Action: action,
ActorProfileID: actingProfileID(r.Context()),
TargetProfileID: profileID,
TargetUserID: userID,
DeviceID: deviceID,
Scope: string(settingscontract.ScopeProfileDevice),
})
w.WriteHeader(http.StatusNoContent)
}
type deviceKey struct {
profileID string
deviceID string
}
// deviceOverrideCounts counts stored profile_device rows per (profile, device).
// ListAllSettingValues spans the whole account, so the caller filters the
// result to the profile it is answering for.
func deviceOverrideCounts(r *http.Request, store userstore.UserStore) (map[deviceKey]int, error) {
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
return nil, err
}
counts := make(map[deviceKey]int)
for _, value := range values {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
counts[deviceKey{profileID: value.ProfileID, deviceID: value.DeviceID}]++
}
return counts, nil
}
// deviceSettingKeys returns the canonical keys stored for one device, so a
// clear can publish an invalidation per key that actually moved.
func (h *DeviceHandler) deviceSettingKeys(
r *http.Request, store userstore.UserStore, profileID, deviceID string,
) ([]string, error) {
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
return nil, err
}
var keys []string
for _, value := range values {
if value.Scope != settingscontract.ScopeProfileDevice {
continue
}
if value.ProfileID != profileID || value.DeviceID != deviceID {
continue
}
keys = append(keys, value.Key)
}
return keys, nil
}
func (h *DeviceHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return nil, false
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, false
}
return store, true
}
+405
View File
@@ -0,0 +1,405 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func newDevicesTestHandler(t *testing.T) (*DeviceHandler, userstore.UserStore) {
t.Helper()
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) +
"?mode=memory&cache=shared"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
store := userdb.NewSQLiteUserStore(db)
ctx := context.Background()
for _, p := range []userstore.Profile{
{ID: "profile-1", Name: "Sam", IsPrimary: true},
{ID: "profile-2", Name: "Robin"},
} {
if err := store.CreateProfile(ctx, p); err != nil {
t.Fatalf("create profile %s: %v", p.ID, err)
}
}
return NewDeviceHandler(testUserStoreProvider{store: store}), store
}
func seedDevice(t *testing.T, store userstore.UserStore, profileID, deviceID, name string) {
t.Helper()
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
t.Fatal("store does not implement DeviceRegistry")
}
if err := registry.RegisterDevice(context.Background(), userstore.DeviceEntry{
ProfileID: profileID, DeviceID: deviceID, DeviceName: name, DevicePlatform: "web",
}); err != nil {
t.Fatalf("registering %s: %v", deviceID, err)
}
}
func seedDeviceValue(t *testing.T, store userstore.UserStore, profileID, deviceID, key, value string) {
t.Helper()
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfileDevice,
ProfileID: profileID,
DeviceID: deviceID,
}, json.RawMessage(value)); err != nil {
t.Fatalf("seeding %s on %s: %v", key, deviceID, err)
}
}
func devicesRequest(method, target, profileID string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set(deviceIDHeader, "device-1")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, profileID))
}
func listDevices(t *testing.T, h *DeviceHandler, query, profileID string) deviceListResponse {
t.Helper()
target := "/devices"
if query != "" {
target += "?" + query
}
rec := httptest.NewRecorder()
h.HandleListDevices(rec, devicesRequest(http.MethodGet, target, profileID))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", target, rec.Code, rec.Body.String())
}
var body deviceListResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
return body
}
// TestListDevices_FiltersToCallingProfile is the security test for this
// endpoint. ListDevices is account-wide by construction in both backends —
// "WHERE user_id" in Postgres and no WHERE at all in the per-user SQLite — so a
// passthrough would show every household member's devices to everyone.
func TestListDevices_FiltersToCallingProfile(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 {
t.Fatalf("returned %d devices, want 1: %+v", len(body.Devices), body.Devices)
}
if body.Devices[0].DeviceID != "device-1" {
t.Errorf("returned device %q, want device-1", body.Devices[0].DeviceID)
}
for _, device := range body.Devices {
if device.ProfileID != "profile-1" {
t.Errorf("leaked device %q from profile %q", device.DeviceID, device.ProfileID)
}
}
}
func TestListDevices_CountsChangedSettings(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Laptop")
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
seedDeviceValue(t, store, "profile-1", "device-2", "playback.subtitle_mode", `"always"`)
// Another profile's row on the same device must not be counted.
seedDeviceValue(t, store, "profile-2", "device-2", "player.seek_cache_enabled", `false`)
body := listDevices(t, handler, "", "profile-1")
counts := map[string]int{}
for _, device := range body.Devices {
counts[device.DeviceID] = device.ChangedCount
}
if counts["device-2"] != 2 {
t.Errorf("device-2 changed_count = %d, want 2", counts["device-2"])
}
if counts["device-1"] != 0 {
t.Errorf("device-1 changed_count = %d, want 0", counts["device-1"])
}
}
func TestListDevices_MarksCurrentDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "This browser")
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
body := listDevices(t, handler, "", "profile-1")
for _, device := range body.Devices {
want := device.DeviceID == "device-1"
if device.IsCurrentDevice != want {
t.Errorf("device %q is_current_device = %v, want %v",
device.DeviceID, device.IsCurrentDevice, want)
}
}
}
func TestListDevices_IncludesProfileName(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Laptop")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 || body.Devices[0].ProfileName != "Sam" {
t.Errorf("profile_name = %q, want Sam", body.Devices[0].ProfileName)
}
}
func routeDevice(
t *testing.T, h *DeviceHandler, method, target, deviceID, profileID string,
handle func(http.ResponseWriter, *http.Request),
) *httptest.ResponseRecorder {
t.Helper()
req := devicesRequest(method, target, profileID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("device_id", deviceID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handle(rec, req)
return rec
}
func TestForgetDevice_RemovesSettingsAndRegistryRow(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if exists {
t.Error("registry row survived forget")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Errorf("setting row survived forget on device %q", got)
}
}
func TestForgetDevice_RejectsOtherProfilesDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9", "device-9",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNotFound {
t.Fatalf("DELETE another profile's device = %d, want 404", rec.Code)
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("another profile's device was removed")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "device-9" {
t.Errorf("another profile's setting row was removed (device %q)", got)
}
}
// A repeated forget reports 404, not 500 or a partial delete: once the device
// is gone this profile has no trace of it, which is indistinguishable from a
// device that was never here — and deliberately so, since the same answer is
// what keeps another profile's device ids from being probeable.
func TestForgetDevice_SecondCallIsNotFound(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNoContent {
t.Fatalf("first DELETE = %d, want 204: %s", rec.Code, rec.Body.String())
}
if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2",
"profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNotFound {
t.Fatalf("second DELETE = %d, want 404", rec.Code)
}
}
// Forgetting a device two profiles share removes only the caller's half.
func TestForgetDevice_LeavesOtherProfilesRowOnSharedDevice(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "shared-tv", "Living Room TV")
seedDevice(t, store, "profile-2", "shared-tv", "Living Room TV")
seedDeviceValue(t, store, "profile-2", "shared-tv", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/shared-tv", "shared-tv",
"profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
stillThere, err := registry.DeviceExists(context.Background(), "profile-2", "shared-tv")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !stillThere {
t.Error("forgetting one profile's half removed the other profile's row")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "shared-tv" {
t.Errorf("the other profile's setting row was removed (device %q)", got)
}
}
func TestClearDeviceSettings_KeepsRegistryRow(t *testing.T) {
handler, store := newDevicesTestHandler(t)
seedDevice(t, store, "profile-1", "device-2", "Apple TV")
seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2/settings", "device-2",
"profile-1", handler.HandleClearDeviceSettings)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("registry row was removed; clear must keep the device")
}
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
t.Errorf("setting row survived clear on device %q", got)
}
}
// --- Household scope ---
func householdDevicesHandler(t *testing.T) (*DeviceHandler, userstore.UserStore) {
t.Helper()
handler, store := newDevicesTestHandler(t)
handler.UserRepo = stubUserRepo{user: &models.User{ID: 1}}
handler.ProfileTokens = access.NewProfileTokenService("test-secret-value-at-least-32-chars", 0)
return handler, store
}
func TestListDevices_PrimarySeesHouseholdWhenRequested(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "scope=household", "profile-1")
if len(body.Devices) != 2 {
t.Fatalf("household scope returned %d devices, want 2: %+v", len(body.Devices), body.Devices)
}
names := map[string]string{}
for _, device := range body.Devices {
names[device.DeviceID] = device.ProfileName
}
if names["device-9"] != "Robin" {
t.Errorf("device-9 profile_name = %q, want Robin", names["device-9"])
}
}
func TestListDevices_NonPrimaryCannotRequestHousehold(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
rec := httptest.NewRecorder()
handler.HandleListDevices(rec, devicesRequest(http.MethodGet, "/devices?scope=household", "profile-2"))
if rec.Code != http.StatusForbidden {
t.Fatalf("non-primary household read = %d, want 403: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "device-1") {
t.Error("refusal leaked another profile's device")
}
}
// Default scope stays private even for the household parent, so the ordinary
// screen cannot show the family's devices by forgetting to ask for less.
func TestListDevices_PrimaryDefaultsToOwnProfile(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
body := listDevices(t, handler, "", "profile-1")
if len(body.Devices) != 1 || body.Devices[0].DeviceID != "device-1" {
t.Fatalf("default scope returned %+v, want only device-1", body.Devices)
}
}
func TestForgetDevice_PrimaryMayForgetHouseholdDevice(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-2", "device-9", "Robin's iPad")
seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`)
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9?profile_id=profile-2",
"device-9", "profile-1", handler.HandleForgetDevice)
if rec.Code != http.StatusNoContent {
t.Fatalf("primary forgetting a household device = %d: %s", rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if exists {
t.Error("device survived the household forget")
}
}
func TestForgetDevice_NonPrimaryCannotForgetSiblingsDevice(t *testing.T) {
handler, store := householdDevicesHandler(t)
seedDevice(t, store, "profile-1", "device-1", "Sam's laptop")
rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-1?profile_id=profile-1",
"device-1", "profile-2", handler.HandleForgetDevice)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-primary forgetting a sibling's device = %d, want 403: %s",
rec.Code, rec.Body.String())
}
registry := store.(userstore.DeviceRegistry)
exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-1")
if err != nil {
t.Fatalf("DeviceExists: %v", err)
}
if !exists {
t.Error("a non-primary profile removed a sibling's device")
}
}
+30 -15
View File
@@ -8,6 +8,8 @@ import (
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -41,18 +43,24 @@ type DiagnosticsService interface {
}
type DiagnosticsHandler struct {
service DiagnosticsService
adminService AdminDiagnosticsService
inflight *diagnosticsInFlightLimiter
logger *slog.Logger
service DiagnosticsService
adminService AdminDiagnosticsService
inflight *diagnosticsInFlightLimiter
chunkSessions *diagnosticsChunkSessions
logger *slog.Logger
}
func NewDiagnosticsHandler(service DiagnosticsService) *DiagnosticsHandler {
handler := &DiagnosticsHandler{
service: service,
inflight: newDiagnosticsInFlightLimiter(4),
logger: slog.Default(),
service: service,
inflight: newDiagnosticsInFlightLimiter(4),
chunkSessions: newDiagnosticsChunkSessions(filepath.Join(os.TempDir(), "silo-diagnostics-uploads")),
logger: slog.Default(),
}
// Reclaim abandoned chunk spool bytes on a timer, not only from later API
// traffic. The handler lives for the process, so the sweeper needs no
// stop signal.
handler.chunkSessions.startSweeper(nil)
if adminService, ok := service.(AdminDiagnosticsService); ok {
handler.adminService = adminService
}
@@ -72,9 +80,14 @@ func (h *DiagnosticsHandler) HandleStatus(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, status)
}
func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
// Extend the read deadline for this request only; the server-wide
// ReadTimeout is too short for slow, large bundle uploads (see the constant).
// extendDiagnosticsUploadDeadlines lifts this request's read (and, when
// includeWrite, write) deadline to diagnosticsUploadReadTimeout. The
// server-wide ReadTimeout (30s) is too short for slow bundle/chunk uploads,
// and the WriteTimeout (120s) can expire after Ingest has already stored the
// report — the lost 201 would make the client retry an upload that
// succeeded. Shared by the single-shot upload, chunk PUT (read only), and
// chunked complete (both).
func (h *DiagnosticsHandler) extendDiagnosticsUploadDeadlines(w http.ResponseWriter, r *http.Request, includeWrite bool) {
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload read deadline not extended",
@@ -82,17 +95,19 @@ func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request
"error", err,
)
}
// Extend the write deadline the same way. cmd/silo's integrated server sets
// WriteTimeout 120s, but a slow upload can take longer than that within the
// read window above; Ingest may store and mark the report ready and then the
// final writeJSON would miss the 120s write deadline, so the client sees a
// timeout and retries a report that already succeeded.
if !includeWrite {
return
}
if err := rc.SetWriteDeadline(time.Now().Add(diagnosticsUploadReadTimeout)); err != nil {
h.diagnosticsLogger().WarnContext(r.Context(), "diagnostics upload write deadline not extended",
"component", "diagnostics",
"error", err,
)
}
}
func (h *DiagnosticsHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
@@ -0,0 +1,482 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/diagnostics"
"github.com/Silo-Server/silo-server/internal/uploads"
)
// Chunked diagnostics upload: a fallback path for bundles that a reverse proxy
// in front of Silo refuses as a single request (nginx's default
// client_max_body_size is 1 MiB; /diagnostics/status advertises bundles up to
// 10 MiB). The client:
//
// 1. POST /diagnostics/reports/uploads {manifest, bundle_bytes}
// 2. PUT /diagnostics/reports/uploads/{id}/chunks/{index} raw bundle bytes, ≤ upload_chunk_bytes each
// 3. POST /diagnostics/reports/uploads/{id}/complete → same 201 as the single-shot upload
// DELETE /diagnostics/reports/uploads/{id} best-effort abandon
//
// The assembled bundle goes through the exact same Ingest path as the
// single-shot endpoint, so every content check (manifest contract, archive
// sha/bytes/entries, quotas, profile attribution) applies identically. The
// manifest is validated only for size at init; Ingest judges it at complete.
//
// Sessions spool to local disk and expire after diagnosticsChunkSessionTTL of
// inactivity (chunk arrivals refresh the deadline). They do NOT hold the
// shared in-flight ingest slot while chunks stream in — only complete
// acquires it, for the ingest itself — so a client that dies mid-upload
// leaves nothing behind but spool bytes the TTL sweep reclaims, and never
// blocks its user's later uploads. Concurrency is bounded by one session per
// user (a new init replaces the previous session) plus a global session cap,
// both reserved atomically in init.
//
// Session state (ownership map + spool files) is process-local. That is fine
// for the integrated single-process server this feature targets; a
// multi-replica API deployment would need sticky routing for the few requests
// of one chunked upload, or a chunk PUT landing on another replica answers
// 404 and the client restarts from init.
const (
// diagnosticsChunkSessionTTL bounds how long an in-progress chunked upload
// may sit idle before its spooled bytes are reclaimed. Generous enough for
// a slow uplink to push 10 MiB in 768 KiB chunks, small enough that
// abandoned sessions don't hold disk for long.
diagnosticsChunkSessionTTL = 15 * time.Minute
// diagnosticsChunkSessionCap bounds concurrent chunked sessions across all
// users, capping worst-case spool disk at cap × max_bundle_bytes
// (≈ 160 MiB at defaults).
diagnosticsChunkSessionCap = 16
)
type diagnosticsChunkInitRequest struct {
// Manifest is the same part-1 manifest the multipart endpoint takes,
// embedded verbatim. Deferring all content validation to Ingest keeps one
// authority for what a valid manifest is.
Manifest json.RawMessage `json:"manifest"`
// BundleBytes is the exact assembled bundle size the client will upload.
// Declared up front so over-limit uploads fail at init, before any chunk
// bytes are spent.
BundleBytes int64 `json:"bundle_bytes"`
}
type diagnosticsChunkInitResponse struct {
UploadID string `json:"upload_id"`
ChunkBytes int64 `json:"chunk_bytes"`
TotalChunks int `json:"total_chunks"`
ExpiresAt string `json:"expires_at"`
}
type diagnosticsChunkStateResponse struct {
ReceivedChunks int `json:"received_chunks"`
TotalChunks int `json:"total_chunks"`
}
// diagnosticsChunkSessions pairs the generic upload session manager with the
// per-session diagnostics state it does not track: the manifest captured at
// init and the owning user (sessions must not be readable or writable across
// accounts).
type diagnosticsChunkSessions struct {
manager *uploads.Manager
mu sync.Mutex
owners map[string]diagnosticsChunkOwner
byUser map[int]string
// reserved counts init calls that have claimed a cap slot but not yet
// registered their created session in owners. Counting reservations and
// registrations together makes the per-user + global-cap admission atomic:
// concurrent inits cannot each pass the checks before any of them
// registers.
reserved map[int]struct{}
}
type diagnosticsChunkOwner struct {
userID int
manifest []byte
}
func newDiagnosticsChunkSessions(spoolDir string) *diagnosticsChunkSessions {
sessions := &diagnosticsChunkSessions{
manager: uploads.NewManager(uploads.ManagerOptions{
RootDir: spoolDir,
TTL: diagnosticsChunkSessionTTL,
MaxChunkSize: diagnostics.UploadChunkBytes,
// MaxSize is enforced per-init against the live max_bundle_bytes
// setting; the manager-level bound is just a hard backstop.
MaxSize: 256 << 20,
}),
owners: make(map[string]diagnosticsChunkOwner),
byUser: make(map[int]string),
reserved: make(map[int]struct{}),
}
// A restart leaves the previous process's spool directories on disk with
// no session map entry to ever expire them; reclaim them now.
sessions.manager.ReclaimOrphanedDirs()
return sessions
}
// startSweeper begins the timed expiry sweep. Split from the constructor so
// tests can run without background goroutines.
func (s *diagnosticsChunkSessions) startSweeper(stop <-chan struct{}) {
s.manager.StartExpirySweeper(time.Minute, stop)
}
// owner returns the session owner entry when id belongs to userID.
func (s *diagnosticsChunkSessions) owner(id string, userID int) (diagnosticsChunkOwner, bool) {
s.mu.Lock()
defer s.mu.Unlock()
owner, ok := s.owners[id]
if !ok || owner.userID != userID {
return diagnosticsChunkOwner{}, false
}
return owner, true
}
// reserve atomically claims the user's session slot and a global cap slot,
// evicting the user's own previous session first (its id is returned for the
// caller to cancel outside the lock — the caller cancels it whether or not
// admission succeeds, since the eviction already happened). A false capOK
// means the cap is full, or a concurrent init by the same user holds an
// unfinished reservation — that racing call must not create a second session.
//
// The cap counts live sessions, unfinished reservations, AND the manager's
// detached-writer sessions: a canceled session whose slow chunk writer is
// still draining holds real disk and a connection until the writer's read
// deadline, so a cancel-and-reinit loop must stall at the cap rather than
// stack unbounded live writers behind it.
func (s *diagnosticsChunkSessions) reserve(userID int) (previousID string, capOK bool) {
detached := s.manager.DetachedWriterSessions()
s.mu.Lock()
defer s.mu.Unlock()
if _, inFlight := s.reserved[userID]; inFlight {
return "", false
}
if id, ok := s.byUser[userID]; ok {
previousID = id
delete(s.owners, id)
delete(s.byUser, userID)
}
if len(s.owners)+len(s.reserved)+detached >= diagnosticsChunkSessionCap {
return previousID, false
}
s.reserved[userID] = struct{}{}
return previousID, true
}
// commit registers the created session under an existing reservation.
func (s *diagnosticsChunkSessions) commit(userID int, id string, manifest []byte) {
s.mu.Lock()
delete(s.reserved, userID)
s.owners[id] = diagnosticsChunkOwner{userID: userID, manifest: manifest}
s.byUser[userID] = id
s.mu.Unlock()
}
// unreserve rolls back a reservation whose session creation failed.
func (s *diagnosticsChunkSessions) unreserve(userID int) {
s.mu.Lock()
delete(s.reserved, userID)
s.mu.Unlock()
}
// drop removes the owner entry. Idempotent; the manager session is the
// caller's to cancel.
func (s *diagnosticsChunkSessions) drop(id string) {
s.mu.Lock()
owner, ok := s.owners[id]
delete(s.owners, id)
if ok && s.byUser[owner.userID] == id {
delete(s.byUser, owner.userID)
}
s.mu.Unlock()
}
// sweepExpired drops owner entries whose manager session has expired or is
// gone, so the owner map (and the cap headroom it counts against) doesn't
// leak alongside the manager's own spool cleanup. Called from init, the only
// entry point that needs the freed headroom.
func (s *diagnosticsChunkSessions) sweepExpired() {
s.mu.Lock()
stale := make([]string, 0, len(s.owners))
for id := range s.owners {
if _, err := s.manager.Peek(id); errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
stale = append(stale, id)
}
}
s.mu.Unlock()
for _, id := range stale {
s.drop(id)
}
}
// HandleChunkedUploadInit handles POST /diagnostics/reports/uploads.
func (h *DiagnosticsHandler) HandleChunkedUploadInit(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
status, ok := h.diagnosticsUploadStatus(w, r, userID)
if !ok {
return
}
maxBundleBytes := status.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = diagnostics.DefaultMaxBundleBytes
}
// Manifest cap plus a small envelope allowance keeps init requests tiny —
// they must themselves fit under restrictive proxy body caps.
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.MaxManifestBytes+diagnosticsMultipartOverheadBytes)
var req diagnosticsChunkInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
writeError(w, http.StatusBadRequest, "invalid_bundle", "Invalid diagnostics upload init")
return
}
if len(req.Manifest) == 0 || int64(len(req.Manifest)) > diagnostics.MaxManifestBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics manifest is too large")
return
}
if req.BundleBytes <= 0 {
writeError(w, http.StatusBadRequest, "invalid_bundle", "bundle_bytes must be positive")
return
}
if req.BundleBytes > maxBundleBytes {
h.logRejected(r.Context(), userID, "too_large")
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Diagnostics upload is too large")
return
}
h.chunkSessions.sweepExpired()
// Atomically evict the user's previous session (a new init replaces it,
// so a client that died mid-upload can start over immediately) and claim
// a cap slot. Cancel the evicted spool outside the lock either way.
previousID, capOK := h.chunkSessions.reserve(userID)
if previousID != "" {
h.abortChunkedUpload(previousID)
}
if !capOK {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
session, err := h.chunkSessions.manager.Create(uploads.CreateRequest{
Filename: "bundle.tar.gz",
SizeBytes: req.BundleBytes,
ChunkSize: diagnostics.UploadChunkBytes,
})
if err != nil {
h.chunkSessions.unreserve(userID)
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
h.chunkSessions.commit(userID, session.ID, append([]byte(nil), req.Manifest...))
writeJSON(w, http.StatusCreated, diagnosticsChunkInitResponse{
UploadID: session.ID,
ChunkBytes: session.ChunkSize,
TotalChunks: session.TotalChunks,
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
})
}
// HandleChunkedUploadChunk handles PUT /diagnostics/reports/uploads/{upload_id}/chunks/{chunk_index}.
func (h *DiagnosticsHandler) HandleChunkedUploadChunk(w http.ResponseWriter, r *http.Request) {
// The integrated server's 30s ReadTimeout kills a 768 KiB chunk arriving
// below ~26 KiB/s — exactly the slow uplinks the chunked fallback exists
// for. The write deadline needs lifting too: the 120s WriteTimeout starts
// at request start, so on a sufficiently slow uplink the stored chunk's
// JSON acknowledgement would miss it and the client would retry an
// already-accepted chunk.
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
chunkIndex, err := strconv.Atoi(chi.URLParam(r, "chunk_index"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid chunk index")
return
}
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, diagnostics.UploadChunkBytes+1)
defer r.Body.Close()
session, err := h.chunkSessions.manager.PutChunk(r.Context(), uploadID, chunkIndex, r.Body, r.ContentLength)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
writeJSON(w, http.StatusOK, diagnosticsChunkStateResponse{
ReceivedChunks: session.ReceivedChunks,
TotalChunks: session.TotalChunks,
})
}
// HandleChunkedUploadComplete handles POST /diagnostics/reports/uploads/{upload_id}/complete.
func (h *DiagnosticsHandler) HandleChunkedUploadComplete(w http.ResponseWriter, r *http.Request) {
// Ingest + object-store write can outlast the server's 120s WriteTimeout;
// without the extension the stored report's 201 would be lost and the
// client would retry an upload that already succeeded (see the single-shot
// handler).
h.extendDiagnosticsUploadDeadlines(w, r, true)
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
owner, ok := h.chunkSessions.owner(uploadID, userID)
if !ok {
writeError(w, http.StatusNotFound, "upload_error", "Upload session not found")
return
}
// Availability can have changed since init (admin toggle, storage loss);
// re-check so a completed spool is not ingested into a disabled feature.
// Only a definitive non-available answer discards the session — a
// transient status load failure (500) keeps it so a retried complete can
// succeed without re-uploading every chunk.
status, statusErr := h.service.Status(r.Context(), userID)
if statusErr != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return
}
if status.Status != diagnostics.StatusAvailable {
h.abortChunkedUpload(uploadID)
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
}
return
}
// The ingest itself shares capacity with single-shot uploads. Check
// before consuming the manager session so a busy answer leaves the
// session intact for a client-side retry of complete.
release, acquired := h.inflight.acquire(userID)
if !acquired {
h.logRejected(r.Context(), userID, "busy")
w.Header().Set("Retry-After", diagnosticsBusyRetryAfter)
writeError(w, http.StatusServiceUnavailable, "busy", "Diagnostics upload capacity is busy")
return
}
defer release()
upload, err := h.chunkSessions.manager.Complete(uploadID)
if err != nil {
if errors.Is(err, uploads.ErrNotFound) || errors.Is(err, uploads.ErrExpired) {
h.chunkSessions.drop(uploadID)
}
statusCode, message := uploadErrorResponse(err)
writeError(w, statusCode, "upload_error", message)
return
}
// The manager session is consumed; a failed ingest is retried by the
// client from a fresh init, never by re-completing a spent session.
defer h.chunkSessions.drop(uploadID)
defer upload.Cleanup()
bundle, err := os.Open(upload.Path)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Diagnostics upload failed")
return
}
defer bundle.Close()
profileID := strings.TrimSpace(r.Header.Get("X-Profile-Id"))
var profileIDPtr *string
if profileID != "" {
profileIDPtr = &profileID
}
result, err := h.service.Ingest(r.Context(), userID, profileIDPtr, owner.manifest, io.Reader(bundle))
if err != nil {
writeDiagnosticsServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, result)
}
// HandleChunkedUploadAbort handles DELETE /diagnostics/reports/uploads/{upload_id}.
func (h *DiagnosticsHandler) HandleChunkedUploadAbort(w http.ResponseWriter, r *http.Request) {
userID, ok := diagnosticsUserID(w, r)
if !ok {
return
}
uploadID := chi.URLParam(r, "upload_id")
if _, ok := h.chunkSessions.owner(uploadID, userID); !ok {
// Unknown or foreign session: abort is a best-effort courtesy, and
// idempotent success leaks nothing about other users' session ids.
w.WriteHeader(http.StatusNoContent)
return
}
h.abortChunkedUpload(uploadID)
w.WriteHeader(http.StatusNoContent)
}
func (h *DiagnosticsHandler) abortChunkedUpload(uploadID string) {
if err := h.chunkSessions.manager.Cancel(uploadID); err != nil && !errors.Is(err, uploads.ErrNotFound) {
h.diagnosticsLogger().Warn("diagnostics chunked upload cancel failed",
"component", "diagnostics",
"error", err,
)
}
h.chunkSessions.drop(uploadID)
}
// diagnosticsUploadStatus loads the feature status and writes the
// disabled/storage-unavailable rejection when uploads cannot proceed,
// mirroring the single-shot endpoint's gate.
func (h *DiagnosticsHandler) diagnosticsUploadStatus(w http.ResponseWriter, r *http.Request, userID int) (diagnostics.Status, bool) {
status, err := h.service.Status(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load diagnostics status")
return diagnostics.Status{}, false
}
switch status.Status {
case diagnostics.StatusDisabled:
writeError(w, http.StatusForbidden, "disabled", "Diagnostics uploads are disabled")
return diagnostics.Status{}, false
case diagnostics.StatusStorageUnavailable:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not configured")
return diagnostics.Status{}, false
case diagnostics.StatusAvailable:
return status, true
default:
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Diagnostics storage is not available")
return diagnostics.Status{}, false
}
}
@@ -0,0 +1,424 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
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/diagnostics"
)
var errFakeStatusUnavailable = errors.New("status store temporarily unavailable")
// newChunkedTestHandler returns a handler whose chunk spool lives in the
// test's temp dir so parallel test runs never share state.
func newChunkedTestHandler(t *testing.T, service DiagnosticsService) *DiagnosticsHandler {
t.Helper()
handler := NewDiagnosticsHandler(service)
handler.chunkSessions = newDiagnosticsChunkSessions(t.TempDir())
return handler
}
func chunkedRouter(handler *DiagnosticsHandler) chi.Router {
r := chi.NewRouter()
r.Post("/uploads", handler.HandleChunkedUploadInit)
r.Put("/uploads/{upload_id}/chunks/{chunk_index}", handler.HandleChunkedUploadChunk)
r.Post("/uploads/{upload_id}/complete", handler.HandleChunkedUploadComplete)
r.Delete("/uploads/{upload_id}", handler.HandleChunkedUploadAbort)
return r
}
func doChunked(t *testing.T, router chi.Router, method, path string, body []byte, claims *auth.Claims) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer token")
if method == http.MethodPost && strings.HasSuffix(path, "/uploads") {
req.Header.Set("Content-Type", "application/json")
}
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func initChunkedUpload(t *testing.T, router chi.Router, bundleBytes int, claims *auth.Claims) diagnosticsChunkInitResponse {
t.Helper()
body := []byte(fmt.Sprintf(`{"manifest":{"ok":true},"bundle_bytes":%d}`, bundleBytes))
rec := doChunked(t, router, http.MethodPost, "/uploads", body, claims)
if rec.Code != http.StatusCreated {
t.Fatalf("init status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp diagnosticsChunkInitResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode init response: %v", err)
}
return resp
}
func TestDiagnosticsChunkedUploadHappyPath(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
if session.ChunkBytes != diagnostics.UploadChunkBytes {
t.Fatalf("chunk_bytes = %d, want %d", session.ChunkBytes, diagnostics.UploadChunkBytes)
}
if session.TotalChunks != 2 {
t.Fatalf("total_chunks = %d, want 2", session.TotalChunks)
}
for index := 0; index < session.TotalChunks; index++ {
start := int64(index) * session.ChunkBytes
end := start + session.ChunkBytes
if end > int64(len(bundle)) {
end = int64(len(bundle))
}
rec := doChunked(t, router, http.MethodPut,
fmt.Sprintf("/uploads/%s/chunks/%d", session.UploadID, index),
bundle[start:end], accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk %d status = %d; body=%s", index, rec.Code, rec.Body.String())
}
}
rec := doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("complete status = %d; body=%s", rec.Code, rec.Body.String())
}
var resp diagnostics.IngestResult
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode complete response: %v", err)
}
if resp.ShortID != "SILO-ABCDEF123456" {
t.Fatalf("short_id = %q", resp.ShortID)
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
if got := string(service.lastManifest); got != `{"ok":true}` {
t.Fatalf("manifest passed to ingest = %q", got)
}
if int(service.lastBundleBytes) != len(bundle) {
t.Fatalf("bundle bytes passed to ingest = %d, want %d", service.lastBundleBytes, len(bundle))
}
// The ingest slot must be free again after completion.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("in-flight slot still held after complete")
}
release()
}
func TestDiagnosticsChunkedUploadInitOverBundleLimit(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.MaxBundleBytes = 1024
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":2048}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
// Rejected init must not leave the limiter slot held.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("in-flight slot held after rejected init")
}
release()
}
func TestDiagnosticsChunkedUploadInitDisabled(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusDisabled
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusForbidden, "disabled")
}
func TestDiagnosticsChunkedUploadForeignSessionRejected(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
other := &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), other)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign chunk status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, other)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign complete status = %d, want 404", rec.Code)
}
// A foreign abort reports success but must not destroy the session.
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+session.UploadID, nil, other)
if rec.Code != http.StatusNoContent {
t.Fatalf("foreign abort status = %d, want 204", rec.Code)
}
rec = doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("owner chunk after foreign abort = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsChunkedUploadIncompleteComplete(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
bundle[:diagnostics.UploadChunkBytes], accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusConflict {
t.Fatalf("incomplete complete status = %d, want 409; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadReinitReplacesPreviousSession(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
first := initChunkedUpload(t, router, 10, accessClaims())
second := initChunkedUpload(t, router, 10, accessClaims())
if first.UploadID == second.UploadID {
t.Fatal("re-init returned the same session id")
}
// The replaced session is gone; the new one accepts chunks.
rec := doChunked(t, router, http.MethodPut, "/uploads/"+first.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusNotFound {
t.Fatalf("replaced-session chunk status = %d, want 404", rec.Code)
}
rec = doChunked(t, router, http.MethodPut, "/uploads/"+second.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("new-session chunk status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+second.UploadID, nil, accessClaims())
if rec.Code != http.StatusNoContent {
t.Fatalf("abort status = %d, want 204", rec.Code)
}
}
func TestDiagnosticsChunkedCompleteBusyKeepsSessionRetryable(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
// Occupy the user's ingest slot: complete must answer busy without
// consuming the spooled session.
release, acquired := handler.inflight.acquire(42)
if !acquired {
t.Fatal("could not occupy in-flight slot")
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "busy")
release()
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadOversizedChunkRejected(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)*2)
session := initChunkedUpload(t, router, len(bundle), accessClaims())
oversized := bytes.Repeat([]byte("x"), int(diagnostics.UploadChunkBytes)+1)
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
oversized, accessClaims())
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized chunk status = %d, want 400 or 413; body=%s", rec.Code, rec.Body.String())
}
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsChunkedUploadManifestTooLarge(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
manifest := `{"pad":"` + strings.Repeat("x", int(diagnostics.MaxManifestBytes)) + `"}`
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":`+manifest+`,"bundle_bytes":10}`), accessClaims())
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
func TestDiagnosticsChunkedCompleteIngestErrorMapsLikeSingleShot(t *testing.T) {
service := newFakeDiagnosticsService()
service.ingestErr = diagnostics.ErrStaleConsent
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusBadRequest, "stale_consent")
// The failed session is consumed; a retry needs a fresh init and the slot
// must be free for it.
initChunkedUpload(t, router, 10, accessClaims())
}
func TestDiagnosticsChunkedUploadConcurrentInitsRespectSlotAtomically(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
// Fire concurrent inits for the same user. The reservation must admit at
// most one at a time — the losers get busy — so a single account can
// never fan out to multiple sessions and eat the global cap.
const attempts = 8
var wg sync.WaitGroup
codes := make([]int, attempts)
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(slot int) {
defer wg.Done()
rec := doChunked(t, router, http.MethodPost, "/uploads",
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
codes[slot] = rec.Code
}(i)
}
wg.Wait()
created := 0
for _, code := range codes {
switch code {
case http.StatusCreated:
created++
case http.StatusServiceUnavailable:
default:
t.Fatalf("unexpected init status %d", code)
}
}
if created < 1 {
t.Fatal("no init succeeded")
}
// However the race resolved, the user must end up with at most one live
// session and no leaked cap slots: after aborting it, a fresh init and
// full upload must succeed.
handler.chunkSessions.mu.Lock()
owned := len(handler.chunkSessions.owners)
reserved := len(handler.chunkSessions.reserved)
handler.chunkSessions.mu.Unlock()
if owned > 1 || reserved != 0 {
t.Fatalf("owners = %d (want ≤1), reserved = %d (want 0)", owned, reserved)
}
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("post-race chunk status = %d; body=%s", rec.Code, rec.Body.String())
}
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("post-race complete status = %d; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsChunkedCompleteTransientStatusErrorKeepsSession(t *testing.T) {
service := newFakeDiagnosticsService()
handler := newChunkedTestHandler(t, service)
router := chunkedRouter(handler)
session := initChunkedUpload(t, router, 10, accessClaims())
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
[]byte("0123456789"), accessClaims())
if rec.Code != http.StatusOK {
t.Fatalf("chunk status = %d", rec.Code)
}
// A transient status failure must answer 500 WITHOUT destroying the fully
// uploaded spool; the retried complete succeeds with no re-upload.
service.setStatusErr(errFakeStatusUnavailable)
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
assertDiagnosticsError(t, rec, http.StatusInternalServerError, "internal_error")
service.setStatusErr(nil)
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
if rec.Code != http.StatusCreated {
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
}
}
func TestDiagnosticsStatusAdvertisesUploadChunkBytes(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.UploadChunkBytes = diagnostics.UploadChunkBytes
handler := newChunkedTestHandler(t, service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/status", nil)
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var payload map[string]any
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode status: %v", err)
}
got, ok := payload["upload_chunk_bytes"].(float64)
if !ok || int64(got) != diagnostics.UploadChunkBytes {
t.Fatalf("upload_chunk_bytes = %v, want %d", payload["upload_chunk_bytes"], diagnostics.UploadChunkBytes)
}
}
+42 -25
View File
@@ -704,29 +704,32 @@ func adminClaims() *auth.Claims {
}
type fakeDiagnosticsService struct {
mu sync.Mutex
status diagnostics.Status
statusCalls int
ingestCalls int
ingestErr error
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
mu sync.Mutex
status diagnostics.Status
statusErr error
statusCalls int
ingestCalls int
ingestErr error
lastManifest []byte
lastBundleBytes int64
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
}
func newFakeDiagnosticsService() *fakeDiagnosticsService {
@@ -747,12 +750,22 @@ func (f *fakeDiagnosticsService) Status(context.Context, int) (diagnostics.Statu
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
if f.statusErr != nil {
return diagnostics.Status{}, f.statusErr
}
return f.status, nil
}
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, _ []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
func (f *fakeDiagnosticsService) setStatusErr(err error) {
f.mu.Lock()
f.statusErr = err
f.mu.Unlock()
}
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, manifestJSON []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
f.mu.Lock()
f.ingestCalls++
f.lastManifest = append([]byte(nil), manifestJSON...)
started := f.started
block := f.block
err := f.ingestErr
@@ -767,9 +780,13 @@ func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, _ [
if err != nil {
return diagnostics.IngestResult{}, err
}
if _, err := io.ReadAll(bundle); err != nil {
consumed, err := io.Copy(io.Discard, bundle)
if err != nil {
return diagnostics.IngestResult{}, err
}
f.mu.Lock()
f.lastBundleBytes = consumed
f.mu.Unlock()
return diagnostics.IngestResult{
ReportID: "11111111-1111-1111-1111-111111111111",
ShortID: "SILO-ABCDEF123456",
@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// eventsCapabilityResponse describes how a client may subscribe to the events
// websocket.
//
// Clients are expected to run a current build rather than negotiate down to an
// old server, so this is not a branch-on-capability contract. It is how a
// client tells the difference between "this server does not do that" and "the
// connection failed", which is what lets it say the deployment is out of date
// instead of failing opaquely: a server predating declared channels ignores
// ?channels=, answers required_action:"subscribe", and closes the connection
// after the grace period, which is indistinguishable from a broken socket.
type eventsCapabilityResponse struct {
SchemaVersion int `json:"schema_version"`
// SubscribeFrame reports the handshake: connect, then send a subscribe
// frame. Always true; named so a future removal is detectable rather than
// silent.
SubscribeFrame bool `json:"subscribe_frame"`
// DeclaredChannels reports that ?channels= is honored on connect, that such
// a connection is exempt from the subscribe grace period, and that its
// hello frame carries required_action:"none".
DeclaredChannels bool `json:"declared_channels"`
// SubscribeGracePeriodSeconds is how long a connection holding no
// subscription may stay silent before it is closed. 0 would mean no
// deadline.
SubscribeGracePeriodSeconds int `json:"subscribe_grace_period_seconds"`
// MaxRequestedChannels is the most channels one selection may name, on the
// URL or in a subscribe frame. Names past it are answered with a single
// too_many_channels rejection rather than one per name.
MaxRequestedChannels int `json:"max_requested_channels"`
// Channels is every channel a client may ask for on this server,
// independent of role — not every channel the server has: the plugins
// channel is host-to-plugin runtime dispatch and is granted to no role, so
// naming it here would advertise a request that can only be refused. What
// the caller may actually subscribe to arrives as available_channels in the
// hello frame, which is role-filtered.
Channels []evt.EventChannel `json:"channels"`
}
// HandleCapability reports the events websocket's subscription capabilities.
//
// Per the v1 rules, new functionality is feature-detected rather than inferred
// from a version. This follows the existing per-subsystem convention
// (/notifications/capability, /playback/capability, /downloads/capability).
//
// A client that finds declared_channels false is talking to a server older than
// its own expectations; the useful response is to tell the user to update the
// server, not to silently fall back.
func (h *EventsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(eventsCapabilityResponse{
SchemaVersion: 1,
SubscribeFrame: true,
DeclaredChannels: true,
SubscribeGracePeriodSeconds: int(subscribeGracePeriod.Seconds()),
MaxRequestedChannels: maxRequestedChannels,
Channels: evt.ClientChannels,
})
}
@@ -0,0 +1,80 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
evt "github.com/Silo-Server/silo-server/internal/events"
)
func TestEventsCapabilityReportsDeclaredChannelSupport(t *testing.T) {
handler := &EventsHandler{}
rec := httptest.NewRecorder()
handler.HandleCapability(rec, httptest.NewRequest(http.MethodGet, "/events/capability", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got eventsCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding capability: %v (%s)", err, rec.Body.String())
}
if !got.DeclaredChannels {
t.Error("declared_channels = false; a client cannot detect ?channels= support")
}
if !got.SubscribeFrame {
t.Error("subscribe_frame = false; the handshake is still supported")
}
if got.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", got.SchemaVersion)
}
// The advertised grace period must be the one the handler actually
// enforces, or a client will size its handshake timeout against fiction.
if want := int(subscribeGracePeriod.Seconds()); got.SubscribeGracePeriodSeconds != want {
t.Errorf("subscribe_grace_period_seconds = %d, want %d", got.SubscribeGracePeriodSeconds, want)
}
if got.MaxRequestedChannels != maxRequestedChannels {
t.Errorf("max_requested_channels = %d, want %d", got.MaxRequestedChannels, maxRequestedChannels)
}
if len(got.Channels) != len(evt.ClientChannels) {
t.Errorf("channels = %v, want all %d client channels", got.Channels, len(evt.ClientChannels))
}
}
// TestEventsCapabilityAdvertisesOnlySubscribableChannels is the point of
// publishing the list at all: a client that requests everything the endpoint
// names must not be refused any of it. The plugins channel is the case — it is
// in evt.AllChannels but granted to no role, admin included, so advertising it
// would send a client to a request that can only fail.
func TestEventsCapabilityAdvertisesOnlySubscribableChannels(t *testing.T) {
handler := &EventsHandler{}
rec := httptest.NewRecorder()
handler.HandleCapability(rec, httptest.NewRequest(http.MethodGet, "/events/capability", nil))
var got eventsCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding capability: %v (%s)", err, rec.Body.String())
}
// An admin, on a profile-bound connection, is the most permissive caller
// there is. Every advertised channel must resolve for them.
_, accepted, rejected := resolveChannelSelection(
got.Channels, allowedChannelsForRole("admin"), "profile-1")
if len(rejected) != 0 {
t.Errorf("capability advertises channels an admin cannot subscribe to: %v", rejected)
}
if len(accepted) != len(got.Channels) {
t.Errorf("accepted %d of %d advertised channels", len(accepted), len(got.Channels))
}
}
+236 -65
View File
@@ -5,6 +5,9 @@ import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/adminjob"
@@ -30,6 +33,41 @@ type taskInfoLister interface {
const maxRealtimeScanSnapshotRuns = 500
// subscribeGracePeriod bounds how long a connection that is not subscribed to
// anything may stay silent before it is closed. It exists to distinguish a
// client mid-handshake from one that connected and stalled; a connection that
// arrived with a usable ?channels= selection is never put on this clock.
const subscribeGracePeriod = 5 * time.Second
// maxRequestedChannels bounds how many distinct channels one selection may
// name. Every refusal is echoed back with its channel name, so without a cap a
// selection of many unknown names is an amplifier: the request is bounded by
// the header/frame limit, the answer was not. Any real client names a subset of
// the ten client channels; the slack is only so a caller learns it overshot
// rather than silently losing channels.
const maxRequestedChannels = 32
// maxChannelNameLength bounds the channel name echoed in a rejection. A name
// longer than any real channel is garbage by definition, and quoting it back in
// full is the second half of the same amplification.
const maxChannelNameLength = 64
// maxEventsFrameBytes bounds an inbound frame on this socket. The only message
// it accepts is a subscribe frame naming at most maxRequestedChannels channels,
// which is orders of magnitude smaller; the limit exists so a client cannot
// make the server buffer an arbitrarily large frame before it is rejected.
const maxEventsFrameBytes = 64 * 1024
// required_action values in the hello frame: whether the client still owes a
// subscribe frame, or already holds a subscription it declared on the URL.
const (
requiredActionSubscribe = "subscribe"
requiredActionNone = "none"
)
// frameTypeSubscribed is the acknowledgement both selection paths answer with.
const frameTypeSubscribed = "subscribed"
type activeScanLister interface {
ListActiveSnapshot(ctx context.Context, limit int) ([]evt.ScanRun, error)
}
@@ -127,6 +165,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
}
defer conn.Close()
configureWebSocket(conn)
conn.SetReadLimit(maxEventsFrameBytes)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
@@ -138,17 +177,52 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
})
allowedChannels := allowedChannelsForRole(claims.Role)
// A connection may declare its channels on the URL instead of sending a
// subscribe frame. That is all an observer needs — subscribe is the only
// inbound message this endpoint accepts — so declaring up front lets a
// read-only client skip the handshake, the grace period, and the write
// half of the socket entirely.
declaredChannels, declared := parseDeclaredChannels(r.URL.Query())
// Resolved before the hello frame so required_action can report what the
// connection actually owes. Resolution is pure — no query, no I/O — so
// nothing here can stall ahead of the reader that starts below; only the
// snapshots the accepted channels produce can, and those still run after
// it.
declaredSubs := make(map[evt.EventChannel]struct{})
var declaredAccepted []evt.EventChannel
var declaredRejected []evt.EventsRejectedChannel
if declared {
declaredSubs, declaredAccepted, declaredRejected =
resolveChannelSelection(declaredChannels, allowedChannels, boundProfileID)
}
// A declaration that yielded no subscription discharges nothing: the client
// still owes a subscribe frame, and saying "none" would send it to wait
// silently for events on a connection due to be closed in five seconds.
requiredAction := requiredActionSubscribe
if len(declaredSubs) > 0 {
requiredAction = requiredActionNone
}
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
SchemaVersion: 1,
ConnectionID: connectionID,
AvailableChannels: allowedChannels,
RequiredAction: "subscribe",
RequiredAction: requiredAction,
}); err != nil {
return
}
// The reader starts before any snapshot query runs. configureWebSocket
// installs an absolute read deadline that only pongs extend, and gorilla
// processes pongs solely inside ReadMessage — so a snapshot slow enough to
// outlast the deadline (a loaded jobs/sessions/scans query) would kill an
// otherwise healthy connection the moment reading began. The handshake path
// is safe for free, building its snapshots downstream of an active reader;
// the declared path has to arrange that ordering deliberately.
readMessages := make(chan []byte, 8)
readDone := make(chan struct{})
go func() {
@@ -166,11 +240,40 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
}
}()
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
subscriptions := declaredSubs
if declared {
// The same subscribed frame the handshake produces, so a client sees
// one acknowledgement shape however it selected its channels — and
// still learns which of its requests were refused, and why.
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
Type: frameTypeSubscribed,
Channels: declaredAccepted,
Rejected: declaredRejected,
}); err != nil {
return
}
for _, channel := range declaredAccepted {
if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil {
return
}
}
}
subscriptions := make(map[evt.EventChannel]struct{})
subscribedOnce := false
// The clock is disarmed by holding a subscription, not by having declared
// one. A declaration that resolved to nothing — ?channels= with no names,
// or a selection every entry of which was refused — leaves the connection
// in exactly the state the grace period exists to reap: subscribed to
// nothing, with no reason to expect it to ever receive a frame, while still
// holding a hub subscriber, two goroutines, and an envelope channel that
// every published event fans into. Such a client still owes a subscribe
// frame, and its rejected list told it why. A nil channel blocks forever,
// which is exactly the disarmed case.
var deadlineC <-chan time.Time
if len(subscriptions) == 0 {
deadline := time.NewTimer(subscribeGracePeriod)
defer deadline.Stop()
deadlineC = deadline.C
}
for {
select {
@@ -178,11 +281,8 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
case <-readDone:
return
case <-deadline.C:
if subscribedOnce {
continue
}
writeWebSocketError(conn, "bad_request", "subscribe is required within 5 seconds")
case <-deadlineC:
writeWebSocketError(conn, "bad_request", "subscribe is required within "+subscribeGracePeriod.String())
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
@@ -197,13 +297,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if !handled {
continue
}
subscribedOnce = true
if !deadline.Stop() {
select {
case <-deadline.C:
default:
}
}
// Obligation discharged: detach the timer so a later tick cannot
// close a connection that has since subscribed. The timer itself
// is stopped by the deferred Stop above.
deadlineC = nil
subscriptions = nextSubs
case env, ok := <-eventsCh:
if !ok {
@@ -263,56 +360,10 @@ func (h *EventsHandler) handleEventsClientMessage(
return nil, false, false
}
allowedSet := make(map[evt.EventChannel]struct{}, len(allowed))
for _, channel := range allowed {
allowedSet[channel] = struct{}{}
}
validSet := make(map[evt.EventChannel]struct{}, len(evt.AllChannels))
for _, channel := range evt.AllChannels {
validSet[channel] = struct{}{}
}
nextSubs := make(map[evt.EventChannel]struct{}, len(message.Channels))
accepted := make([]evt.EventChannel, 0, len(message.Channels))
rejected := make([]evt.EventsRejectedChannel, 0)
for _, channel := range message.Channels {
if _, ok := validSet[channel]; !ok {
writeWebSocketError(conn, "bad_request", "Invalid channel")
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "invalid channel"),
)
return nil, false, false
}
if _, ok := allowedSet[channel]; !ok {
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: "forbidden",
Message: "Admin access required",
})
continue
}
// The notifications channel is profile-scoped: it requires a
// connection bound to a profile via a websocket ticket.
if channel == evt.ChannelNotifications && boundProfileID == "" {
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: "profile_required",
Message: "A profile-bound websocket ticket is required",
})
continue
}
if _, seen := nextSubs[channel]; seen {
continue
}
nextSubs[channel] = struct{}{}
accepted = append(accepted, channel)
}
nextSubs, accepted, rejected := resolveChannelSelection(message.Channels, allowed, boundProfileID)
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
Type: "subscribed",
Type: frameTypeSubscribed,
RequestID: message.RequestID,
Channels: accepted,
Rejected: rejected,
@@ -329,11 +380,131 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
// resolveChannelSelection decides which of the requested channels a connection
// may subscribe to. It is the single place that answers that question, shared
// by the URL-declared path and the subscribe frame so the two cannot drift.
//
// Every rejection is reported rather than fatal: an unknown, forbidden, or
// profile-requiring channel costs the caller that channel and nothing else.
// Closing the connection instead would take down every other channel it holds
// over one bad name, and a client cannot always know which channels its role
// allows before it asks.
//
// Because refusals are answered instead of closing the connection, the answer
// has to be bounded independently of the request: at most maxRequestedChannels
// distinct names are considered, and an overrun is reported once rather than
// per excess name.
func resolveChannelSelection(
requested []evt.EventChannel,
allowed []evt.EventChannel,
boundProfileID string,
) (map[evt.EventChannel]struct{}, []evt.EventChannel, []evt.EventsRejectedChannel) {
allowedSet := make(map[evt.EventChannel]struct{}, len(allowed))
for _, channel := range allowed {
allowedSet[channel] = struct{}{}
}
validSet := make(map[evt.EventChannel]struct{}, len(evt.ClientChannels))
for _, channel := range evt.ClientChannels {
validSet[channel] = struct{}{}
}
capacity := min(len(requested), maxRequestedChannels)
subs := make(map[evt.EventChannel]struct{}, capacity)
accepted := make([]evt.EventChannel, 0, capacity)
rejected := make([]evt.EventsRejectedChannel, 0, capacity)
// A repeated channel is answered once whether it was accepted or refused.
// Only the accepted side deduplicated before this change, so asking twice
// for one forbidden channel produced two identical rejections.
seen := make(map[evt.EventChannel]struct{}, capacity)
reject := func(channel evt.EventChannel, code, message string) {
// A garbage name is echoed back only far enough to be recognizable.
if len(channel) > maxChannelNameLength {
channel = channel[:maxChannelNameLength]
}
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: code,
Message: message,
})
}
for _, channel := range requested {
if _, dup := seen[channel]; dup {
continue
}
if len(seen) >= maxRequestedChannels {
// One entry for the whole overrun, not one per name: the point of
// the cap is that the response cannot grow with the request.
reject("", "too_many_channels",
"At most "+strconv.Itoa(maxRequestedChannels)+" channels may be requested at once")
break
}
seen[channel] = struct{}{}
switch {
case !contains(validSet, channel):
reject(channel, "unknown_channel", "Unknown channel")
case !contains(allowedSet, channel):
// Deliberately not "admin access required": the caller's role is
// the usual reason a channel is refused but not the only one, and a
// remedy that does not exist reads as a bug in the client's own
// permissions rather than a channel it was never going to get.
reject(channel, "forbidden", "This channel is not available to this connection")
// The notifications channel is profile-scoped: it requires a
// connection bound to a profile via a websocket ticket.
case channel == evt.ChannelNotifications && boundProfileID == "":
reject(channel, "profile_required", "A profile-bound websocket ticket is required")
default:
subs[channel] = struct{}{}
accepted = append(accepted, channel)
}
}
return subs, accepted, rejected
}
func contains(set map[evt.EventChannel]struct{}, channel evt.EventChannel) bool {
_, ok := set[channel]
return ok
}
// parseDeclaredChannels reads the optional ?channels= selection a connection
// may declare on the URL, letting a read-only observer skip the subscribe
// handshake entirely. An absent parameter returns ok=false, which keeps the
// handshake (and its grace period) in force; an empty or all-blank value is a
// deliberate declaration of nothing, so it returns ok=true and no channels.
//
// Every occurrence of the parameter is read, not just the first. Repeating a
// query parameter is as natural a spelling as a comma-separated list, and
// honoring one and silently discarding the rest loses channels with no
// diagnostic — the connection would come up subscribed to less than it asked
// for and report nothing rejected.
func parseDeclaredChannels(query url.Values) ([]evt.EventChannel, bool) {
values, ok := query["channels"]
if !ok {
return nil, false
}
channels := make([]evt.EventChannel, 0, 4)
for _, value := range values {
for _, raw := range strings.Split(value, ",") {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
channels = append(channels, evt.EventChannel(name))
}
}
return channels, true
}
func allowedChannelsForRole(role string) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
evt.ChannelUserSettings,
evt.ChannelNotifications,
}
if role == "admin" {
@@ -0,0 +1,638 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
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/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// eventsWSTestConn dials the events websocket against a handler authenticated
// as the given claims, and returns a frame reader. These tests go through the
// real socket rather than calling the handler directly because the behavior
// under test — what a connection is subscribed to before it has said anything,
// and whether it survives the grace period — only exists in the connection
// loop.
func eventsWSTestConn(t *testing.T, hub *evt.Hub, claims *auth.Claims, query string) (
*websocket.Conn,
func(wantType string) map[string]json.RawMessage,
) {
t.Helper()
return eventsWSTestConnWithHandler(t, &EventsHandler{hub: hub}, claims, query)
}
func eventsWSTestConnWithHandler(
t *testing.T,
handler *EventsHandler,
claims *auth.Claims,
query string,
) (*websocket.Conn, func(wantType string) map[string]json.RawMessage) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), claims)
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
t.Cleanup(server.Close)
conn, resp, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+query, nil)
if err != nil {
t.Fatalf("dialing events websocket: %v", err)
}
if resp != nil && resp.Body != nil {
t.Cleanup(func() { _ = resp.Body.Close() })
}
t.Cleanup(func() { _ = conn.Close() })
readFrame := func(wantType string) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("reading %s frame: %v", wantType, err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"`+wantType+`"` {
t.Fatalf("frame type = %s, want %q (frame: %s)", frame["type"], wantType, data)
}
return frame
}
return conn, readFrame
}
// TestEventsWebSocketDeclaredChannelsSkipHandshake is the point of the feature:
// a connection that named its channels on the URL receives events without ever
// writing to the socket.
func TestEventsWebSocketDeclaredChannelsSkipHandshake(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("declared channel was not accepted: %s", subscribed["channels"])
}
// Accepted channels hydrate with a snapshot, exactly as the handshake does.
if snapshot := readFrame("snapshot"); string(snapshot["channel"]) != `"user_settings"` {
t.Fatalf("snapshot channel = %s, want user_settings", snapshot["channel"])
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketDeclaredChannelsSurviveGracePeriod guards the core promise
// of the change: an observer that connects and never speaks stays connected.
// Before this, it was closed with a policy violation after five seconds.
func TestEventsWebSocketDeclaredChannelsSurviveGracePeriod(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=user_settings")
readFrame("hello")
readFrame("subscribed")
readFrame("snapshot")
// Stay silent well past the deadline that would have closed a
// handshake-style connection, then confirm the socket still delivers.
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection did not survive the grace period: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"event"` {
t.Fatalf("frame type = %s, want \"event\" (frame: %s)", frame["type"], data)
}
}
// TestEventsWebSocketEmptyDeclarationStillClosed pins what disarms the grace
// period: holding a subscription, not having spelled ?channels=. A declaration
// that resolved to nothing leaves the connection in the exact state the clock
// exists to reap — no subscriptions, no reason to expect a frame, but still a
// hub subscriber, two goroutines, and an envelope channel every published
// event fans into.
func TestEventsWebSocketEmptyDeclarationStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
tests := []struct {
name string
query string
}{
{name: "declares no channels", query: "?channels="},
// Every name refused: a non-admin naming only an admin channel is
// subscribed to nothing, exactly as if it had named nothing.
{name: "every declared channel refused", query: "?channels=sessions"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, tt.query)
hello := readFrame("hello")
// The obligation is real, so the hello frame has to say so rather
// than sending the client off to wait silently on a doomed socket.
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
readFrame("subscribed")
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
if _, _, err := conn.ReadMessage(); !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
})
}
}
// TestEventsWebSocketPartialDeclarationSurvives is the boundary case on the
// other side: one accepted channel among refusals is a live subscription, so
// the connection is not on the clock.
func TestEventsWebSocketPartialDeclarationSurvives(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
hello := readFrame("hello")
if string(hello["required_action"]) != `"none"` {
t.Errorf("required_action = %s, want \"none\"", hello["required_action"])
}
readFrame("subscribed")
readFrame("snapshot")
time.Sleep(subscribeGracePeriod + time.Second)
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("a partially accepted declaration did not survive the grace period: %v", err)
}
}
// TestEventsWebSocketRepeatedChannelsParameter covers the other natural
// spelling of a selection. Honoring only the first occurrence dropped the rest
// with an empty rejected array — the connection came up subscribed to less than
// it asked for and reported nothing wrong.
func TestEventsWebSocketRepeatedChannelsParameter(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"},
"?channels=catalog&channels=user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
for _, want := range []string{`"catalog"`, `"user_settings"`} {
if !strings.Contains(string(subscribed["channels"]), want) {
t.Errorf("channel %s was dropped: %s", want, subscribed["channels"])
}
}
}
// TestEventsWebSocketSilentConnectionStillClosed pins the other half: the grace
// period still applies to a connection that declared nothing, so the URL path
// relaxes the rule rather than removing it.
func TestEventsWebSocketSilentConnectionStillClosed(t *testing.T) {
if testing.Short() {
t.Skip("waits out the subscribe grace period in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
hello := readFrame("hello")
if string(hello["required_action"]) != `"subscribe"` {
t.Errorf("required_action = %s, want \"subscribe\"", hello["required_action"])
}
if err := conn.SetReadDeadline(time.Now().Add(subscribeGracePeriod + 5*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
// The error frame, then the close.
if _, _, err := conn.ReadMessage(); err != nil {
t.Fatalf("reading error frame: %v", err)
}
_, _, err := conn.ReadMessage()
if err == nil {
t.Fatal("silent connection was not closed after the grace period")
}
if !websocket.IsCloseError(err, websocket.ClosePolicyViolation) {
t.Fatalf("close error = %v, want policy violation", err)
}
}
// TestEventsWebSocketDeclaredChannelsCannotEscalate is the authorization
// guarantee: naming an admin-only channel on the URL must not grant it, and
// must not grant the events published to it either.
func TestEventsWebSocketDeclaredChannelsCannotEscalate(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
_, readFrame := eventsWSTestConn(t, hub,
&auth.Claims{UserID: 1, Role: "user"}, "?channels=sessions,user_settings")
readFrame("hello")
subscribed := readFrame("subscribed")
if strings.Contains(string(subscribed["channels"]), `"sessions"`) {
t.Fatalf("non-admin was granted the sessions channel: %s", subscribed["channels"])
}
if !strings.Contains(string(subscribed["rejected"]), `"forbidden"`) {
t.Errorf("sessions was not reported as forbidden: %s", subscribed["rejected"])
}
// The permitted channel in the same request still landed.
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Errorf("a forbidden channel denied the rest of the request: %s", subscribed["channels"])
}
readFrame("snapshot") // user_settings
// An admin-only event on the refused channel must not be delivered. Publish
// it first, then a permitted event; receiving the second without the first
// proves the first was filtered rather than merely slow.
if err := hub.PublishJSON(context.Background(), evt.ChannelSessions, "sessions.replaced", nil,
evt.PublishOptions{AdminOnly: true}); err != nil {
t.Fatalf("publishing admin-only event: %v", err)
}
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
event := readFrame("event")
if string(event["channel"]) != `"user_settings"` {
t.Fatalf("received an event on a channel this connection was refused: %s", event["channel"])
}
}
// TestEventsWebSocketUnknownChannelDoesNotCloseConnection covers the third
// change: an unrecognized channel name used to close the socket outright,
// taking down every other channel the client held over one bad name.
func TestEventsWebSocketUnknownChannelDoesNotCloseConnection(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
RequestID: "r1",
Channels: []evt.EventChannel{"not_a_channel", evt.ChannelUserSettings},
}); err != nil {
t.Fatalf("sending subscribe: %v", err)
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["rejected"]), `"unknown_channel"`) {
t.Errorf("unknown channel was not reported as such: %s", subscribed["rejected"])
}
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("an unknown channel denied the valid one alongside it: %s", subscribed["channels"])
}
// The connection is still usable.
readFrame("snapshot")
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
if event := readFrame("event"); string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
}
// TestEventsWebSocketRejectsOversizeFrame covers the one case that is still
// fatal, and has to be: a frame is buffered whole before its type can be read,
// so an oversize frame cannot be answered with a rejection the way a bad
// channel name can — refusing it politely would mean first doing the thing the
// limit exists to prevent.
func TestEventsWebSocketRejectsOversizeFrame(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
conn, readFrame := eventsWSTestConn(t, hub, &auth.Claims{UserID: 1, Role: "user"}, "")
readFrame("hello")
oversize := `{"type":"subscribe","channels":["` +
strings.Repeat("x", maxEventsFrameBytes*2) + `"]}`
// The write itself may fail once the server has already torn the connection
// down, which is the same outcome; only accepting the frame is a failure.
if err := conn.WriteMessage(websocket.TextMessage, []byte(oversize)); err != nil {
return
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
if _, _, err := conn.ReadMessage(); err == nil {
t.Fatal("a frame past the read limit was accepted")
}
}
// slowTaskLister stalls the tasks snapshot long enough to outlast the read
// deadline configureWebSocket installs at connect.
type slowTaskLister struct{ delay time.Duration }
func (s slowTaskLister) ListTasks(bool) []taskmanager.TaskInfo {
time.Sleep(s.delay)
return []taskmanager.TaskInfo{}
}
// TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot pins the ordering the
// declared path depends on. configureWebSocket sets an absolute read deadline
// that only pongs extend, and gorilla processes pongs solely inside
// ReadMessage — so if snapshot queries ran before the reader goroutine started,
// a snapshot slower than the deadline would kill a healthy connection the
// instant reading began. The handshake path gets this for free by building
// snapshots downstream of an active reader; the declared path arranges it
// deliberately.
func TestEventsWebSocketDeclaredChannelsSurviveSlowSnapshot(t *testing.T) {
if testing.Short() {
t.Skip("stalls a snapshot past the websocket read deadline in real time")
}
hub := evt.NewHub("test", &cache.NoopEventBus{})
handler := &EventsHandler{
hub: hub,
tasks: slowTaskLister{delay: wsPingInterval + wsPongTimeout + 2*time.Second},
}
conn, readFrame := eventsWSTestConnWithHandler(t, handler,
&auth.Claims{UserID: 1, Role: "admin"}, "?channels=tasks")
readFrame("hello")
readFrame("subscribed")
// The snapshot arrives late by design; allow for the stall plus slack.
if err := conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout + 15*time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("connection died during a slow snapshot: %v", err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"snapshot"` {
t.Fatalf("frame type = %s, want \"snapshot\" (frame: %s)", frame["type"], data)
}
// And the connection is still live afterwards.
if err := hub.PublishJSON(context.Background(), evt.ChannelTasks, "tasks.changed",
map[string]string{"id": "t1"}, evt.PublishOptions{}); err != nil {
t.Fatalf("publishing: %v", err)
}
if event := readFrame("event"); string(event["channel"]) != `"tasks"` {
t.Errorf("event channel = %s, want tasks", event["channel"])
}
}
func TestParseDeclaredChannels(t *testing.T) {
tests := []struct {
name string
query string
want []evt.EventChannel
declared bool
}{
{
name: "absent parameter keeps the handshake",
query: "",
want: nil,
declared: false,
},
{
name: "empty value declares nothing, but still declares",
query: "channels=",
want: []evt.EventChannel{},
declared: true,
},
{
name: "whitespace and empty entries are dropped",
query: "channels=catalog,%20,,user_state%20",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState},
declared: true,
},
{
// Repeating the parameter is as natural a spelling as one comma
// list; reading only the first occurrence lost the rest silently.
name: "every occurrence of the parameter is read",
query: "channels=catalog&channels=user_state,user_settings",
want: []evt.EventChannel{evt.ChannelCatalog, evt.ChannelUserState, evt.ChannelUserSettings},
declared: true,
},
{
name: "a repeated parameter with only blank values still declares",
query: "channels=&channels=%20",
want: []evt.EventChannel{},
declared: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query, err := url.ParseQuery(tt.query)
if err != nil {
t.Fatalf("parsing query: %v", err)
}
got, declared := parseDeclaredChannels(query)
if declared != tt.declared {
t.Fatalf("declared = %v, want %v", declared, tt.declared)
}
if len(got) != len(tt.want) {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("channels = %v, want %v", got, tt.want)
}
}
})
}
}
func TestResolveChannelSelectionDeniesByDefault(t *testing.T) {
allowed := allowedChannelsForRole("user")
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelSessions, "bogus", evt.ChannelNotifications},
allowed,
"", // unbound: notifications requires a profile-bound ticket
)
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("nothing should have been accepted: subs=%v accepted=%v", subs, accepted)
}
codes := make(map[string]evt.EventChannel, len(rejected))
for _, r := range rejected {
codes[r.Code] = r.Channel
}
if codes["forbidden"] != evt.ChannelSessions {
t.Errorf("sessions not rejected as forbidden: %v", rejected)
}
if codes["unknown_channel"] != "bogus" {
t.Errorf("bogus not rejected as unknown: %v", rejected)
}
if codes["profile_required"] != evt.ChannelNotifications {
t.Errorf("notifications not rejected as profile_required: %v", rejected)
}
}
func TestResolveChannelSelectionDeduplicates(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelJobs, evt.ChannelJobs},
allowedChannelsForRole("admin"),
"",
)
if len(subs) != 1 || len(accepted) != 1 {
t.Fatalf("duplicate channel was not collapsed: subs=%v accepted=%v", subs, accepted)
}
if len(rejected) != 0 {
t.Fatalf("unexpected rejections: %v", rejected)
}
}
// TestResolveChannelSelectionDeduplicatesRejections covers the other half of
// dedup: a channel asked for twice is answered once whether it was accepted or
// refused. Only the accepted side deduplicated before.
func TestResolveChannelSelectionDeduplicatesRejections(t *testing.T) {
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{
evt.ChannelSessions, evt.ChannelSessions, // forbidden for a user
"bogus", "bogus", // unknown
},
allowedChannelsForRole("user"),
"",
)
if len(rejected) != 2 {
t.Fatalf("rejected = %v, want one entry per distinct channel", rejected)
}
}
// TestResolveChannelSelectionBoundsTheAnswer is the amplification guard.
// Refusals quote the name they refuse, so once an unknown channel stopped
// closing the connection, the response grew with the request: a large selection
// of distinct garbage names produced a far larger subscribed frame, buffered
// server-side. The answer has to be bounded independently of the request.
func TestResolveChannelSelectionBoundsTheAnswer(t *testing.T) {
requested := make([]evt.EventChannel, 0, 5000)
for i := range 5000 {
requested = append(requested, evt.EventChannel("bogus-"+strconv.Itoa(i)))
}
subs, accepted, rejected := resolveChannelSelection(requested, allowedChannelsForRole("user"), "")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("garbage names were accepted: subs=%v accepted=%v", subs, accepted)
}
// Every considered name is refused, plus exactly one entry for the overrun.
if len(rejected) != maxRequestedChannels+1 {
t.Fatalf("rejected %d entries, want %d", len(rejected), maxRequestedChannels+1)
}
overrun := rejected[len(rejected)-1]
if overrun.Code != "too_many_channels" {
t.Errorf("last rejection code = %q, want too_many_channels", overrun.Code)
}
// The response must not scale with the request, whatever the constants are.
encoded, err := json.Marshal(evt.EventsSubscribedMessage{
Type: "subscribed",
Channels: accepted,
Rejected: rejected,
})
if err != nil {
t.Fatalf("encoding subscribed frame: %v", err)
}
if len(encoded) > 8*1024 {
t.Errorf("subscribed frame is %d bytes for a garbage selection", len(encoded))
}
}
// TestResolveChannelSelectionTruncatesLongNames covers the per-name half of the
// same concern: one enormous name is as good an amplifier as many small ones.
func TestResolveChannelSelectionTruncatesLongNames(t *testing.T) {
long := evt.EventChannel(strings.Repeat("x", 4096))
_, _, rejected := resolveChannelSelection(
[]evt.EventChannel{long}, allowedChannelsForRole("user"), "")
if len(rejected) != 1 {
t.Fatalf("rejected = %v, want one entry", rejected)
}
if len(rejected[0].Channel) != maxChannelNameLength {
t.Errorf("echoed name is %d bytes, want it truncated to %d",
len(rejected[0].Channel), maxChannelNameLength)
}
}
// TestResolveChannelSelectionRefusesPluginsChannel pins that the host-to-plugin
// dispatch channel is not reachable from a client connection, for any role.
func TestResolveChannelSelectionRefusesPluginsChannel(t *testing.T) {
for _, role := range []string{"user", "admin"} {
t.Run(role, func(t *testing.T) {
subs, accepted, rejected := resolveChannelSelection(
[]evt.EventChannel{evt.ChannelPlugins}, allowedChannelsForRole(role), "profile-1")
if len(subs) != 0 || len(accepted) != 0 {
t.Fatalf("%s was granted the plugins channel: %v", role, accepted)
}
if len(rejected) != 1 || rejected[0].Code != "unknown_channel" {
t.Errorf("rejected = %v, want a single unknown_channel entry", rejected)
}
})
}
}
@@ -0,0 +1,106 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
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/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// TestEventsWebSocketDeliversUserSettingsToNonAdmins goes through the real
// websocket rather than subscribing on the Hub directly, because that is the
// only place the channel's authorization lives: dropping ChannelUserSettings
// from allowedChannelsForRole answers the subscribe with {code:"forbidden"},
// and dropping it from evt.AllChannels closes the connection as an invalid
// channel — either way the server would keep publishing change events no
// client could ever receive, while every Hub-level test stayed green.
func TestEventsWebSocketDeliversUserSettingsToNonAdmins(t *testing.T) {
hub := evt.NewHub("test", &cache.NoopEventBus{})
handler := &EventsHandler{hub: hub}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The router authenticates before the handler runs; a plain (non-admin)
// user is the role whose devices must hear their own settings change.
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, Role: "user"})
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
defer server.Close()
conn, resp, err := websocket.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("dialing events websocket: %v", err)
}
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
defer func() { _ = conn.Close() }()
readFrame := func(wantType string) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("setting read deadline: %v", err)
}
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("reading %s frame: %v", wantType, err)
}
var frame map[string]json.RawMessage
if err := json.Unmarshal(data, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%s)", err, data)
}
if string(frame["type"]) != `"`+wantType+`"` {
t.Fatalf("frame type = %s, want %q (frame: %s)", frame["type"], wantType, data)
}
return frame
}
hello := readFrame("hello")
if !strings.Contains(string(hello["available_channels"]), `"user_settings"`) {
t.Fatalf("hello does not offer user_settings: %s", hello["available_channels"])
}
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
RequestID: "r1",
Channels: []evt.EventChannel{evt.ChannelUserSettings},
}); err != nil {
t.Fatalf("sending subscribe: %v", err)
}
subscribed := readFrame("subscribed")
if !strings.Contains(string(subscribed["channels"]), `"user_settings"`) {
t.Fatalf("subscribe was not accepted: %s", subscribed["channels"])
}
if rejected, present := subscribed["rejected"]; present && string(rejected) != "null" && string(rejected) != "[]" {
t.Fatalf("subscribe was rejected: %s", rejected)
}
// The accepted subscription hydrates with a snapshot frame first.
snapshot := readFrame("snapshot")
if string(snapshot["channel"]) != `"user_settings"` {
t.Fatalf("snapshot channel = %s, want user_settings", snapshot["channel"])
}
// A change event addressed to this user must reach the connection.
publishUserSettingsEvent(context.Background(), hub, 1, "profile-1",
"playback.subtitle_language", "profile")
event := readFrame("event")
if string(event["channel"]) != `"user_settings"` {
t.Errorf("event channel = %s, want user_settings", event["channel"])
}
if string(event["event"]) != `"`+userSettingsChangedEvent+`"` {
t.Errorf("event = %s, want %q", event["event"], userSettingsChangedEvent)
}
}
+10 -3
View File
@@ -155,7 +155,7 @@ func (h *PersonalDataHandler) HandleListFavorites(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: len(favorites) == limit})
}
// HandleCheckFavorite handles GET /favorites/{item_id}.
@@ -304,6 +304,9 @@ func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list watchlist")
return
}
// Capture has_more from the raw store page, before hidden-series filtering
// shrinks it — a full page that filters down still has more to fetch.
watchlistHasMore := len(entries) == limit
entries, err = h.filterHiddenWatchlistSeries(r.Context(), store, profileID, entries)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to filter watchlist")
@@ -316,7 +319,7 @@ func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: watchlistHasMore})
}
// filterHiddenWatchlistSeries drops fully-watched series from a watchlist page.
@@ -479,7 +482,7 @@ func (h *PersonalDataHandler) HandleListHistory(w http.ResponseWriter, r *http.R
return
}
writeJSON(w, http.StatusOK, itemsListResponse{Items: items})
writeJSON(w, http.StatusOK, itemsListResponse{Items: items, HasMore: len(entries) == limit})
}
// HandleRemoveHistory handles POST /history/remove.
@@ -552,6 +555,10 @@ func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http
// itemsListResponse wraps a list of items for favorites/watchlist responses.
type itemsListResponse struct {
Items []itemListResponse `json:"items"`
// HasMore lets clients paginate these personal lists. Computed from the
// raw store page size (== limit), not the resolved item count, so
// catalog-missing / hidden entries don't make a full page look final.
HasMore bool `json:"has_more"`
}
// resolveItems fetches full media item data for a list of entries.
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"context"
"fmt"
"net/http"
"github.com/Silo-Server/silo-server/internal/access"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// userLookup is the slice of the user repository the household check needs: a
// profile token is only valid against the access-policy revision it was minted
// for, so verifying one means reading the user's current revision.
type userLookup interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// canManageHousehold reports whether the caller may act for the whole household
// — every profile on their own account — rather than only for themselves.
//
// Server admins always may. Otherwise the caller's active profile must be the
// one flagged is_primary, which is the household parent and is deliberately
// *not* the server-wide admin role: a household parent manages their family,
// an admin manages the server.
//
// When that primary profile has a PIN, management additionally requires a valid
// X-Profile-Token from /profiles/{id}/verify-pin. Without that, a client could
// walk past a profile lock by sending only X-Profile-Id.
//
// This is a policy boundary for well-behaved clients rather than a defense
// against the account holder: every profile on an account shares one login
// session, so X-Profile-Id is self-asserted (see the note in
// internal/api/middleware/auth.go). It is the same boundary profile management
// has always used, and it is applied here so household settings management is
// guarded and auditable rather than implicit.
func canManageHousehold(
r *http.Request,
store userstore.UserStore,
users userLookup,
tokens *access.ProfileTokenService,
) (bool, error) {
ctx := r.Context()
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
if activeProfileID == "" {
activeProfileID = r.Header.Get("X-Profile-Id")
}
if activeProfileID == "" {
return false, nil
}
active, err := store.GetProfile(ctx, activeProfileID)
if err != nil {
return false, err
}
if active == nil {
return false, nil
}
if !active.IsPrimary {
return false, nil
}
if active.PINHash == "" {
return true, nil
}
if err := verifyProfileToken(r, users, tokens, active.ID); err != nil {
return false, err
}
return true, nil
}
// verifyProfileToken checks the X-Profile-Token a PIN-locked profile must
// present. Missing dependencies fail closed: a handler wired without a token
// service cannot verify a PIN, and "cannot verify" is not "verified".
func verifyProfileToken(
r *http.Request,
users userLookup,
tokens *access.ProfileTokenService,
profileID string,
) error {
if users == nil || tokens == nil {
return access.ErrProfileUnverified
}
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.SessionID == "" {
return access.ErrProfileUnverified
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return access.ErrProfileUnverified
}
user, err := users.GetByID(r.Context(), userID)
if err != nil {
return fmt.Errorf("loading user policy: %w", err)
}
if user == nil {
return access.ErrProfileUnverified
}
profileClaims, err := tokens.Validate(r.Header.Get("X-Profile-Token"))
if err != nil {
return err
}
if profileClaims.UserID != userID ||
profileClaims.SessionID != claims.SessionID ||
profileClaims.ProfileID != profileID ||
profileClaims.PolicyRevision != user.AccessPolicyRevision {
return access.ErrProfileUnverified
}
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package handlers
import (
"context"
"database/sql"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
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/models"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type stubUserRepo struct {
user *models.User
}
func (s stubUserRepo) GetByID(context.Context, int) (*models.User, error) {
return s.user, nil
}
func newHouseholdTestStore(t *testing.T) userstore.UserStore {
t.Helper()
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) +
"?mode=memory&cache=shared"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
return userdb.NewSQLiteUserStore(db)
}
func householdRequest(profileID string, admin bool, profileToken string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
claims := &auth.Claims{UserID: 1, SessionID: "session-1"}
if admin {
claims.Role = "admin"
}
ctx := apimw.SetClaims(req.Context(), claims)
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
if profileToken != "" {
req.Header.Set("X-Profile-Token", profileToken)
}
return req.WithContext(ctx)
}
// TestCanManageHousehold pins the household-parent boundary before it is shared
// with the settings routes. The rule is deliberately narrow: a server admin, or
// the one profile flagged is_primary — and when that profile carries a PIN, a
// verified profile token, so sending only X-Profile-Id cannot walk past a
// profile lock.
func TestCanManageHousehold(t *testing.T) {
ctx := context.Background()
setup := func(t *testing.T, pin string) (userstore.UserStore, *access.ProfileTokenService) {
t.Helper()
store := newHouseholdTestStore(t)
if err := store.CreateProfile(ctx, userstore.Profile{
ID: "primary", Name: "Sam", IsPrimary: true,
}); err != nil {
t.Fatalf("create primary: %v", err)
}
if err := store.CreateProfile(ctx, userstore.Profile{
ID: "child", Name: "Robin",
}); err != nil {
t.Fatalf("create child: %v", err)
}
if pin != "" {
if err := store.UpdateProfile(ctx, "primary", userstore.UpdateProfileInput{
PIN: &pin,
}); err != nil {
t.Fatalf("set pin: %v", err)
}
}
return store, access.NewProfileTokenService("test-secret-value-at-least-32-chars", 0)
}
t.Run("admin always may", func(t *testing.T) {
store, tokens := setup(t, "")
// An admin with no active profile at all still manages.
ok, err := canManageHousehold(householdRequest("", true, ""), store, nil, tokens)
if err != nil || !ok {
t.Fatalf("admin = (%v, %v), want (true, nil)", ok, err)
}
})
t.Run("primary without pin may", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("primary", false, ""), store, nil, tokens)
if err != nil || !ok {
t.Fatalf("primary = (%v, %v), want (true, nil)", ok, err)
}
})
t.Run("non-primary may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("child", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("non-primary = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("no active profile may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("no profile = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("unknown profile may not", func(t *testing.T) {
store, tokens := setup(t, "")
ok, err := canManageHousehold(householdRequest("ghost", false, ""), store, nil, tokens)
if err != nil || ok {
t.Fatalf("unknown profile = (%v, %v), want (false, nil)", ok, err)
}
})
t.Run("primary with pin and no token may not", func(t *testing.T) {
store, tokens := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
_, err := canManageHousehold(householdRequest("primary", false, ""), store, repo, tokens)
if !errors.Is(err, access.ErrProfileUnverified) {
t.Fatalf("pin without token err = %v, want ErrProfileUnverified", err)
}
})
t.Run("primary with pin and valid token may", func(t *testing.T) {
store, tokens := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
token, _, err := tokens.Mint(access.ProfileTokenClaims{
UserID: 1, SessionID: "session-1", ProfileID: "primary", PolicyRevision: 0,
})
if err != nil {
t.Fatalf("issuing token: %v", err)
}
ok, err := canManageHousehold(householdRequest("primary", false, token), store, repo, tokens)
if err != nil || !ok {
t.Fatalf("pin with token = (%v, %v), want (true, nil)", ok, err)
}
})
// Nil dependencies must fail closed. A settings handler built without a
// token service is not a reason to skip the PIN check.
t.Run("pin with no token service may not", func(t *testing.T) {
store, _ := setup(t, "1234")
repo := stubUserRepo{user: &models.User{ID: 1}}
_, err := canManageHousehold(householdRequest("primary", false, "tok"), store, repo, nil)
if !errors.Is(err, access.ErrProfileUnverified) {
t.Fatalf("nil token service err = %v, want ErrProfileUnverified", err)
}
})
}
+89
View File
@@ -0,0 +1,89 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/invitations"
)
// InvitationHandler handles the public (unauthenticated) claim endpoints.
type InvitationHandler struct {
service *invitations.Service
}
// NewInvitationHandler creates a new InvitationHandler.
func NewInvitationHandler(service *invitations.Service) *InvitationHandler {
return &InvitationHandler{service: service}
}
type invitationLookupResponse struct {
Email string `json:"email"`
InviterName string `json:"inviter_name,omitempty"`
ServerName string `json:"server_name"`
ExpiresAt time.Time `json:"expires_at"`
ShowTour bool `json:"show_tour"`
}
type acceptInvitationRequest struct {
Password string `json:"password"`
}
// HandleLookupInvitation handles GET /invitations/{token}. Unknown, expired,
// revoked, and accepted tokens return an identical 404 so a probe learns
// nothing about which.
func (h *InvitationHandler) HandleLookupInvitation(w http.ResponseWriter, r *http.Request) {
result, err := h.service.Lookup(r.Context(), chi.URLParam(r, "token"))
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "This invitation is invalid or has expired")
return
}
writeJSON(w, http.StatusOK, invitationLookupResponse{
Email: result.Email,
InviterName: result.InviterName,
ServerName: result.ServerName,
ExpiresAt: result.ExpiresAt,
ShowTour: result.ShowTour,
})
}
// HandleAcceptInvitation handles POST /invitations/{token}/accept. On
// success it returns the same login response shape as signup, so clients
// reuse their existing session plumbing.
func (h *InvitationHandler) HandleAcceptInvitation(w http.ResponseWriter, r *http.Request) {
var req acceptInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "weak_password", "Password must be at least 8 characters")
return
}
pair, user, err := h.service.Accept(
r.Context(),
chi.URLParam(r, "token"),
req.Password,
r.UserAgent(),
clientip.FromContext(r.Context()),
)
if err != nil {
switch {
case errors.Is(err, invitations.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "This invitation is invalid or has expired")
case errors.Is(err, invitations.ErrNotClaimable):
writeError(w, http.StatusConflict, "already_used", "This invitation has already been used")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
}
return
}
writeJSON(w, http.StatusCreated, buildLoginResponse(pair, user, nil))
}
+327 -9
View File
@@ -21,6 +21,7 @@ import (
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
@@ -47,6 +48,33 @@ type MetadataRefreshRequester interface {
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
}
// TrailerRefreshRequester starts a viewer-triggered trailer fetch for one item
// and reports whether it was queued, in cooldown, or disabled by every
// containing library. Implemented by *metadata.MetadataService.
type TrailerRefreshRequester interface {
RequestTrailersRefresh(ctx context.Context, contentID string) (metadata.TrailerRefreshOutcome, error)
}
// trailerItemAccess resolves and authorizes the item behind the trailer
// refresh route. The concrete *catalog.ItemRepository satisfies it; the
// interface keeps the handler testable without a database.
type trailerItemAccess interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
// trailerSeasonLookup and trailerEpisodeLookup resolve the content IDs that are
// not media_items rows. Season and episode detail pages carry their own IDs, so
// without these a client that asks for their trailers would get a 404 that
// looks like a missing item instead of the contracted "wrong type" answer.
type trailerSeasonLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
}
type trailerEpisodeLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
}
type LocalWatchEventDispatcher interface {
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
}
@@ -70,6 +98,11 @@ type ItemsHandler struct {
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
metadataRefreshRequester MetadataRefreshRequester
trailerRefreshRequester TrailerRefreshRequester
trailerItemAccess trailerItemAccess
trailerSeasonLookup trailerSeasonLookup
trailerEpisodeLookup trailerEpisodeLookup
trailerRefreshLimiter ratelimit.RateLimiter
localWatchDispatcher LocalWatchEventDispatcher
ebookProgressStore EbookReaderProgressLister
ebookReadStateStore EbookReadStateStore
@@ -130,6 +163,47 @@ func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequ
h.metadataRefreshRequester = requester
}
// SetTrailerRefreshLimiter wires the process's configured rate limiter into the
// trailer fetch action, so the per-user budget is shared across instances when
// the deployment runs the Redis backend. A private in-memory limiter would give
// each instance its own allowance for the same user, and the per-item database
// cooldown cannot make up the difference — it bounds one item, while this
// budget bounds how many distinct items a user can start refreshes for.
//
// Call before SetTrailerRefreshRequester, which falls back to a private
// in-memory limiter when none is set (single-instance deployments, and any
// deployment with rate limiting turned off entirely).
func (h *ItemsHandler) SetTrailerRefreshLimiter(limiter ratelimit.RateLimiter) {
if h == nil || limiter == nil {
return
}
h.trailerRefreshLimiter = limiter
}
// SetTrailerRefreshRequester wires the viewer-facing trailer fetch action.
// Leaving it unset disables the route's behavior (503), so the router only
// registers it when the metadata service is available.
func (h *ItemsHandler) SetTrailerRefreshRequester(requester TrailerRefreshRequester) {
if h == nil {
return
}
h.trailerRefreshRequester = requester
if h.trailerRefreshLimiter == nil {
h.trailerRefreshLimiter = ratelimit.NewMemoryLimiter()
}
if h.trailerItemAccess == nil && h.itemRepo != nil {
h.trailerItemAccess = h.itemRepo
}
// Seasons and episodes are not media_items rows, so the route needs these
// to tell "this ID is an episode" from "no such content".
if h.trailerSeasonLookup == nil && h.seasonRepo != nil {
h.trailerSeasonLookup = h.seasonRepo
}
if h.trailerEpisodeLookup == nil && h.episodeRepo != nil {
h.trailerEpisodeLookup = h.episodeRepo
}
}
func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) {
if h == nil || h.catalogResolver == nil || provider == nil {
return
@@ -428,6 +502,238 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, detail)
}
// trailerRefreshRate bounds how often one user may trigger trailer fetches
// across all items. The per-item cooldown enforced by the metadata service is
// the real budget; this only keeps a misbehaving client from hammering the
// endpoint (same shape as personRefreshRate).
var trailerRefreshRate = ratelimit.Rate{
RequestsPerSecond: 10,
RequestsPerMinute: 10,
Burst: 10,
}
// trailerRefreshLimiterKey namespaces this action's per-user counter. The
// limiter behind it is normally the process-wide one shared with the rate-limit
// middleware, whose keys are namespaced the same way ("ip:", "key:").
func trailerRefreshLimiterKey(userID int) string {
return "trailers:" + strconv.Itoa(userID)
}
// trailerRefreshResponse is the body of the trailer refresh endpoint.
// NextAllowedAt is present only for the cooldown status.
type trailerRefreshResponse struct {
Status string `json:"status"`
NextAllowedAt string `json:"next_allowed_at,omitempty"`
}
// trailerRefreshCapabilityResponse tells a client whether this server offers
// the viewer-facing trailer fetch, following the per-subsystem convention
// (/events/capability, /playback/capability, /ebooks/capability).
//
// Without it the only signal is a 404 from the POST, which a client cannot
// tell apart from a missing item — and the route is registered conditionally
// (it needs the metadata service to implement the optional interface), so
// "this build has the feature" is not the same question as "this deployment
// serves it". A client that finds refresh false should hide the action rather
// than offer a button that cannot work.
type trailerRefreshCapabilityResponse struct {
SchemaVersion int `json:"schema_version"`
// Refresh reports that POST /items/{id}/trailers/refresh is served here.
Refresh bool `json:"refresh"`
// CooldownSeconds is the per-item window between viewer-triggered
// refreshes, so a client can explain the wait without having received a
// cooldown response first.
CooldownSeconds int `json:"cooldown_seconds"`
// Statuses is every value the refresh endpoint's status field may take.
Statuses []string `json:"statuses"`
// SupportedTypes is the item types the action applies to; nothing else
// carries remote videos, so clients should not show the action elsewhere.
SupportedTypes []string `json:"supported_types"`
}
// HandleTrailerRefreshCapability reports whether the trailer refresh action is
// available. GET /api/v1/items/trailers/capability.
//
// It answers even when the feature is unwired, because "refresh": false is the
// answer in that case; the router registers it unconditionally so a client
// never has to interpret a 404 on the probe itself.
func (h *ItemsHandler) HandleTrailerRefreshCapability(w http.ResponseWriter, _ *http.Request) {
enabled := h != nil && h.trailerRefreshRequester != nil && h.trailerItemAccess != nil
resp := trailerRefreshCapabilityResponse{
SchemaVersion: 1,
Refresh: enabled,
Statuses: []string{},
SupportedTypes: []string{},
}
if enabled {
resp.CooldownSeconds = int(metadata.TrailerRefreshCooldown / time.Second)
resp.Statuses = []string{
metadata.TrailerRefreshStatusQueued,
metadata.TrailerRefreshStatusCooldown,
metadata.TrailerRefreshStatusDisabled,
}
resp.SupportedTypes = []string{"movie", "series"}
}
writeJSON(w, http.StatusOK, resp)
}
// HandleRequestTrailersRefresh handles POST /api/v1/items/{id}/trailers/refresh:
// any authenticated viewer with access to a movie or series may ask the server
// to fetch its remote trailers, at most once per item per cooldown window.
//
// "cooldown" and "disabled" are expected client-rendered states, not errors,
// so they answer 200; 429 stays reserved for the per-user limiter. The access
// check runs before the metadata service is called so a caller who cannot see
// the item can never consume its cooldown slot. A season or episode ID resolves
// through its own table to 400 unsupported-type; only genuinely unknown content
// answers 404.
func (h *ItemsHandler) HandleRequestTrailersRefresh(w http.ResponseWriter, r *http.Request) {
if h == nil || h.trailerRefreshRequester == nil || h.trailerItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Trailer refresh is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.trailerRefreshLimiter != nil {
// The limiter may be the process-wide one the middleware uses, so the
// key is namespaced: an unprefixed user id would share a counter with
// whatever else keys on the same string.
result := h.trailerRefreshLimiter.Allow(r.Context(), trailerRefreshLimiterKey(userID), trailerRefreshRate)
if !result.Allowed {
if result.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(result.RetryAfter.Seconds()))))
}
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many trailer refresh requests")
return
}
}
target, err := h.resolveTrailerRefreshTarget(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to look up item", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
// Authorize against the series for a season or episode ID, exactly as the
// on-view translation route does, so an unsupported-type answer never
// leaks the existence of content the caller cannot see.
if err := h.trailerItemAccess.EnsureAccessible(r.Context(), target.accessContentID, h.accessFilter(r)); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to authorize item", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
// Only movie and series detail responses carry videos/extras, so anything
// else — another media_items type, or a season/episode ID, which is not a
// media_items row at all — is a client bug rather than an empty result.
if !target.supportsTrailers {
writeError(w, http.StatusBadRequest, "unsupported_type", "Trailers are only available for movies and series")
return
}
outcome, err := h.trailerRefreshRequester.RequestTrailersRefresh(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "trailers: failed to request refresh", "component", "api",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers")
return
}
switch outcome.Status {
case metadata.TrailerRefreshStatusQueued:
writeJSON(w, http.StatusAccepted, trailerRefreshResponse{Status: outcome.Status})
case metadata.TrailerRefreshStatusCooldown:
resp := trailerRefreshResponse{Status: outcome.Status}
if outcome.NextAllowedAt != nil {
resp.NextAllowedAt = outcome.NextAllowedAt.UTC().Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, resp)
case metadata.TrailerRefreshStatusDisabled:
writeJSON(w, http.StatusOK, trailerRefreshResponse{Status: outcome.Status})
default:
slog.ErrorContext(r.Context(), "trailers: unexpected refresh outcome", "component", "api",
"content_id", contentID, "status", outcome.Status)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request trailers")
}
}
// trailerRefreshTarget is what a content ID on the trailer refresh route turned
// out to be: whether trailers apply to it at all, and which item ID authorizes
// it (a season or episode is authorized through its series).
type trailerRefreshTarget struct {
supportsTrailers bool
accessContentID string
}
// resolveTrailerRefreshTarget identifies the content behind an ID the same way
// the on-view translation route does. Seasons and episodes live in their own
// tables, so a media_items miss is not proof the content is absent — falling
// through to those lookups is what lets a real episode ID answer 400
// unsupported-type instead of a misleading 404.
func (h *ItemsHandler) resolveTrailerRefreshTarget(ctx context.Context, contentID string) (trailerRefreshTarget, error) {
item, err := h.trailerItemAccess.GetByID(ctx, contentID)
switch {
case err == nil && item != nil:
return trailerRefreshTarget{
supportsTrailers: item.Type == "movie" || item.Type == "series",
accessContentID: contentID,
}, nil
case err == nil, errors.Is(err, catalog.ErrItemNotFound):
// Fall through to the season and episode lookups.
default:
return trailerRefreshTarget{}, err
}
if h.trailerSeasonLookup != nil {
season, err := h.trailerSeasonLookup.GetByID(ctx, contentID)
switch {
case err == nil && season != nil:
return trailerRefreshTarget{accessContentID: season.SeriesID}, nil
case err == nil, errors.Is(err, catalog.ErrSeasonNotFound):
default:
return trailerRefreshTarget{}, err
}
}
if h.trailerEpisodeLookup != nil {
episode, err := h.trailerEpisodeLookup.GetByID(ctx, contentID)
switch {
case err == nil && episode != nil:
return trailerRefreshTarget{accessContentID: episode.SeriesID}, nil
case err == nil, errors.Is(err, catalog.ErrEpisodeNotFound):
default:
return trailerRefreshTarget{}, err
}
}
return trailerRefreshTarget{}, catalog.ErrItemNotFound
}
// HandleMarkWatched handles POST /watched/{id}.
func (h *ItemsHandler) HandleMarkWatched(w http.ResponseWriter, r *http.Request) {
h.handleSetWatchedState(w, r, true)
@@ -1478,7 +1784,18 @@ func (h *ItemsHandler) toSeasonResponseFromEpisodes(
s = localized
}
}
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
}
// seasonResponseFromEpisodes maps a season that has already been localized.
// List endpoints use this after LocalizeSeasonModels so they do not repeat the
// localization query for every row.
func (h *ItemsHandler) seasonResponseFromEpisodes(
r *http.Request,
s *models.Season,
episodes []*models.Episode,
userData *catalog.SeasonUserData,
) seasonResponse {
resp := seasonResponse{
ContentID: s.ContentID,
SeasonNumber: s.SeasonNumber,
@@ -1814,15 +2131,16 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
}
}
@@ -0,0 +1,571 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/catalog"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/ratelimit"
)
type fakeTrailerItemAccess struct {
items map[string]*models.MediaItem
ensureErr map[string]error
getErr map[string]error
checked []string
}
func (f *fakeTrailerItemAccess) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
if err := f.getErr[contentID]; err != nil {
return nil, err
}
if item := f.items[contentID]; item != nil {
return item, nil
}
return nil, catalog.ErrItemNotFound
}
func (f *fakeTrailerItemAccess) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
f.checked = append(f.checked, contentID)
return f.ensureErr[contentID]
}
type fakeTrailerRefreshRequester struct {
outcome metadata.TrailerRefreshOutcome
err error
requests []string
}
func (f *fakeTrailerRefreshRequester) RequestTrailersRefresh(_ context.Context, contentID string) (metadata.TrailerRefreshOutcome, error) {
f.requests = append(f.requests, contentID)
if f.err != nil {
return metadata.TrailerRefreshOutcome{}, f.err
}
return f.outcome, nil
}
// fakeTrailerSeasonLookup and fakeTrailerEpisodeLookup stand in for the season
// and episode tables. Their content IDs are real and resolvable, they are just
// not media_items rows — which is exactly why the route needs them.
type fakeTrailerSeasonLookup map[string]*models.Season
func (f fakeTrailerSeasonLookup) GetByID(_ context.Context, contentID string) (*models.Season, error) {
if season := f[contentID]; season != nil {
return season, nil
}
return nil, catalog.ErrSeasonNotFound
}
type fakeTrailerEpisodeLookup map[string]*models.Episode
func (f fakeTrailerEpisodeLookup) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
if episode := f[contentID]; episode != nil {
return episode, nil
}
return nil, catalog.ErrEpisodeNotFound
}
func newTrailerRefreshHandler(
access *fakeTrailerItemAccess,
requester *fakeTrailerRefreshRequester,
) *ItemsHandler {
return &ItemsHandler{
trailerItemAccess: access,
trailerRefreshRequester: requester,
trailerRefreshLimiter: ratelimit.NewMemoryLimiter(),
trailerSeasonLookup: fakeTrailerSeasonLookup{},
trailerEpisodeLookup: fakeTrailerEpisodeLookup{},
}
}
func newTrailerRefreshRequest(contentID string, userID int) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/"+contentID+"/trailers/refresh", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", contentID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: userID, Role: "user", TokenType: auth.TokenTypeAccess})
ctx = apimw.SetProfileID(ctx, "profile-1")
ctx = access.SetScope(ctx, access.Scope{UserID: userID, ProfileID: "profile-1"})
return req.WithContext(ctx)
}
func decodeTrailerResponse(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body %q: %v", rr.Body.String(), err)
}
return body
}
// The router discovers both seams by type assertion, so a signature drift
// would silently unregister the route rather than fail the build.
func TestTrailerRefreshWiringAssertionsHold(t *testing.T) {
var svc any = (*metadata.MetadataService)(nil)
if _, ok := svc.(TrailerRefreshRequester); !ok {
t.Fatal("*metadata.MetadataService must satisfy handlers.TrailerRefreshRequester")
}
var repo any = (*catalog.ItemRepository)(nil)
if _, ok := repo.(trailerItemAccess); !ok {
t.Fatal("*catalog.ItemRepository must satisfy trailerItemAccess")
}
// SetTrailerRefreshRequester adopts these from the handler's own repos, so
// drift here would silently downgrade every episode ID back to a 404.
var seasons any = (*catalog.SeasonRepository)(nil)
if _, ok := seasons.(trailerSeasonLookup); !ok {
t.Fatal("*catalog.SeasonRepository must satisfy trailerSeasonLookup")
}
var episodes any = (*catalog.EpisodeRepository)(nil)
if _, ok := episodes.(trailerEpisodeLookup); !ok {
t.Fatal("*catalog.EpisodeRepository must satisfy trailerEpisodeLookup")
}
}
func TestTrailersRefreshReturnsQueued(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "queued" {
t.Fatalf("status field = %v, want queued", body["status"])
}
if _, ok := body["next_allowed_at"]; ok {
t.Fatalf("queued response must omit next_allowed_at, got %v", body)
}
if len(requester.requests) != 1 || requester.requests[0] != "movie-1" {
t.Fatalf("requests = %v, want [movie-1]", requester.requests)
}
}
func TestTrailersRefreshReturnsCooldownWithNextAllowedAt(t *testing.T) {
next := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{
Status: metadata.TrailerRefreshStatusCooldown,
NextAllowedAt: &next,
},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("series-1", 7))
// Cooldown is an expected client-rendered state, not an error: 200, and
// 429 stays reserved for the per-user limiter.
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "cooldown" {
t.Fatalf("status field = %v, want cooldown", body["status"])
}
if got := body["next_allowed_at"]; got != next.Format(time.RFC3339) {
t.Fatalf("next_allowed_at = %v, want %s", got, next.Format(time.RFC3339))
}
}
func TestTrailersRefreshReturnsDisabled(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusDisabled},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusOK, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if body["status"] != "disabled" {
t.Fatalf("status field = %v, want disabled", body["status"])
}
if _, ok := body["next_allowed_at"]; ok {
t.Fatalf("disabled response must omit next_allowed_at, got %v", body)
}
}
// Only movie and series detail responses carry videos, so any other
// media_items type is a client bug rather than an empty result. These are the
// types that actually exist as media_items rows; episodes and seasons live in
// their own tables and are covered separately below.
func TestTrailersRefreshRejectsNonMovieSeriesTypes(t *testing.T) {
for _, itemType := range []string{"audiobook", "ebook", "manga"} {
t.Run(itemType, func(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"item-1": {ContentID: "item-1", Type: itemType}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("item-1", 7))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
}
})
}
}
// Episodes and seasons are not media_items rows, so the item lookup misses on
// their real content IDs. Without the fallbacks the route would answer 404
// "Item not found" for content that plainly exists; the contract is 400
// unsupported-type. Authorization runs against the parent series, as on the
// on-view translation route.
func TestTrailersRefreshRejectsEpisodeAndSeasonIDsWith400(t *testing.T) {
tests := []struct {
name string
contentID string
wantAccess string
}{
{name: "episode", contentID: "episode-1", wantAccess: "series-1"},
{name: "season", contentID: "season-1", wantAccess: "series-1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
handler.trailerSeasonLookup = fakeTrailerSeasonLookup{
"season-1": {ContentID: "season-1", SeriesID: "series-1"},
}
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest(tc.contentID, 7))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusBadRequest, rr.Body.String())
}
body := decodeTrailerResponse(t, rr)
if code, _ := body["error"].(string); code != "unsupported_type" {
t.Fatalf("error code = %v, want unsupported_type (%s)", body["error"], rr.Body.String())
}
if len(itemAccess.checked) != 1 || itemAccess.checked[0] != tc.wantAccess {
t.Fatalf("access checks = %v, want [%s]", itemAccess.checked, tc.wantAccess)
}
if len(requester.requests) != 0 {
t.Fatalf("unsupported type must not reach the service, got %v", requester.requests)
}
})
}
}
// An episode inside a series the caller cannot see must not be distinguishable
// from content that does not exist, so the access check runs before the type
// answer.
func TestTrailersRefreshEpisodeInInaccessibleSeriesReturns404(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"series-1": {ContentID: "series-1", Type: "series"}},
ensureErr: map[string]error{"series-1": catalog.ErrItemNotFound},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
handler.trailerEpisodeLookup = fakeTrailerEpisodeLookup{
"episode-1": {ContentID: "episode-1", SeriesID: "series-1"},
}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("episode-1", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
}
}
// An unauthorized caller must be turned away before the metadata service is
// asked, so it can never burn the item's cooldown slot.
func TestTrailersRefreshDeniedAccessReturns404WithoutConsumingCooldown(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{"movie-1": catalog.ErrItemNotFound},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(itemAccess.checked) != 1 {
t.Fatalf("access checks = %v, want one check", itemAccess.checked)
}
if len(requester.requests) != 0 {
t.Fatalf("denied request must not reach the service, got %v", requester.requests)
}
}
func TestTrailersRefreshMissingItemReturns404(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("missing", 7))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusNotFound, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("missing item must not reach the service, got %v", requester.requests)
}
}
func TestTrailersRefreshRequiresAuthentication(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{}
handler := newTrailerRefreshHandler(itemAccess, requester)
req := httptest.NewRequest(http.MethodPost, "/api/v1/items/movie-1/trailers/refresh", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", "movie-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusUnauthorized, rr.Body.String())
}
if len(requester.requests) != 0 {
t.Fatalf("unauthenticated request must not reach the service, got %v", requester.requests)
}
}
// The per-user limiter is the abuse guard in front of the per-item cooldown:
// once a user exhausts the burst it answers 429 with Retry-After.
func TestTrailersRefreshRateLimitsPerUser(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
limited := false
for i := 0; i < int(trailerRefreshRate.RequestsPerMinute)+5; i++ {
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code == http.StatusTooManyRequests {
limited = true
if rr.Header().Get("Retry-After") == "" {
t.Fatal("429 response must carry Retry-After")
}
break
}
}
if !limited {
t.Fatal("expected the per-user limiter to reject a burst of requests")
}
// A different user is unaffected — the limiter keys on the user id.
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 8))
if rr.Code != http.StatusAccepted {
t.Fatalf("second user status = %d, want %d (%s)", rr.Code, http.StatusAccepted, rr.Body.String())
}
}
func TestTrailersRefreshUnconfiguredReturns503(t *testing.T) {
handler := &ItemsHandler{}
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusServiceUnavailable, rr.Body.String())
}
}
func TestTrailersRefreshServiceErrorReturns500(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{err: errors.New("database is down")}
handler := newTrailerRefreshHandler(itemAccess, requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d (%s)", rr.Code, http.StatusInternalServerError, rr.Body.String())
}
}
// The capability probe is what lets a client tell "this server does not have
// the trailer action" from "that item does not exist", so it must answer on
// both a wired and an unwired handler.
func TestTrailerRefreshCapability(t *testing.T) {
t.Run("wired", func(t *testing.T) {
h := newTrailerRefreshHandler(&fakeTrailerItemAccess{}, &fakeTrailerRefreshRequester{})
rr := httptest.NewRecorder()
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := decodeTrailerResponse(t, rr)
if body["refresh"] != true {
t.Fatalf("refresh = %v, want true", body["refresh"])
}
if got, want := body["cooldown_seconds"], float64(metadata.TrailerRefreshCooldown/time.Second); got != want {
t.Fatalf("cooldown_seconds = %v, want %v", got, want)
}
// The advertised statuses are the contract the client switches on, so
// they must be the service's constants rather than a stale copy.
statuses, _ := body["statuses"].([]any)
want := []string{
metadata.TrailerRefreshStatusQueued,
metadata.TrailerRefreshStatusCooldown,
metadata.TrailerRefreshStatusDisabled,
}
if len(statuses) != len(want) {
t.Fatalf("statuses = %v, want %v", statuses, want)
}
for i, status := range want {
if statuses[i] != status {
t.Fatalf("statuses[%d] = %v, want %q", i, statuses[i], status)
}
}
})
t.Run("unwired", func(t *testing.T) {
h := &ItemsHandler{}
rr := httptest.NewRecorder()
h.HandleTrailerRefreshCapability(rr, httptest.NewRequest(http.MethodGet, "/items/trailers/capability", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 — the probe itself must never 404", rr.Code)
}
body := decodeTrailerResponse(t, rr)
if body["refresh"] != false {
t.Fatalf("refresh = %v, want false", body["refresh"])
}
})
}
// recordingLimiter captures the keys an action limiter is called with.
type recordingLimiter struct {
keys []string
allowed bool
}
func (l *recordingLimiter) Allow(_ context.Context, key string, _ ratelimit.Rate) ratelimit.AllowResult {
l.keys = append(l.keys, key)
return ratelimit.AllowResult{Allowed: l.allowed, RetryAfter: time.Second}
}
func (l *recordingLimiter) Close() {}
// The action's budget must be enforced by the process's configured limiter, or
// a Redis deployment gives every instance an independent allowance for the same
// user and multiplies the stated budget by the instance count. The per-item
// database cooldown cannot compensate: it bounds one item, while this bounds
// how many distinct items a user can start refreshes for.
func TestTrailersRefreshUsesTheInjectedSharedLimiter(t *testing.T) {
itemAccess := &fakeTrailerItemAccess{
items: map[string]*models.MediaItem{"movie-1": {ContentID: "movie-1", Type: "movie"}},
ensureErr: map[string]error{},
}
requester := &fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
}
handler := newTrailerRefreshHandler(itemAccess, requester)
shared := &recordingLimiter{allowed: false}
handler.SetTrailerRefreshLimiter(shared)
// The requester wiring must not replace an injected limiter with a private
// in-memory one, which is the whole point of injecting it.
handler.SetTrailerRefreshRequester(requester)
rr := httptest.NewRecorder()
handler.HandleRequestTrailersRefresh(rr, newTrailerRefreshRequest("movie-1", 7))
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d — the injected limiter's verdict was ignored (%s)",
rr.Code, http.StatusTooManyRequests, rr.Body.String())
}
if len(shared.keys) != 1 {
t.Fatalf("shared limiter consulted %d times, want 1", len(shared.keys))
}
// The limiter may be the process-wide one, whose keyspace is shared with
// the rate-limit middleware ("ip:", "key:"), so this action's keys have to
// be namespaced too.
if shared.keys[0] != trailerRefreshLimiterKey(7) {
t.Fatalf("limiter key = %q, want the namespaced %q", shared.keys[0], trailerRefreshLimiterKey(7))
}
if shared.keys[0] == "7" {
t.Fatal("an unprefixed user id would collide with other keyspaces in a shared limiter")
}
if len(requester.requests) != 0 {
t.Fatalf("a rate-limited request must not reach the service, got %v", requester.requests)
}
}
// Rate limiting can be disabled outright (or the database unavailable), in
// which case there is no shared limiter to inject. The action keeps its own
// in-memory guard rather than running unbounded.
func TestTrailersRefreshFallsBackToAPrivateLimiter(t *testing.T) {
handler := &ItemsHandler{}
handler.SetTrailerRefreshLimiter(nil)
handler.SetTrailerRefreshRequester(&fakeTrailerRefreshRequester{
outcome: metadata.TrailerRefreshOutcome{Status: metadata.TrailerRefreshStatusQueued},
})
if handler.trailerRefreshLimiter == nil {
t.Fatal("the action must keep a limiter even when no shared one is configured")
}
}
+48 -1
View File
@@ -4,8 +4,10 @@ import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
@@ -234,7 +236,7 @@ func TestRemoveJellyfinCompatWebDisablesWebSetting(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": t.TempDir(),
"jellyfin_compat.web_install_dir": asyncWebInstallRoot(t),
}}
published := map[string]string{}
handler := &AdminHandler{
@@ -337,3 +339,48 @@ func TestPersistJellyfinCompatWebInstallSettingsEnablesWebUI(t *testing.T) {
t.Fatalf("jellyfin_compat.web_source_url = %q", got)
}
}
// asyncWebInstallRoot returns a temp dir for a handler that removes or installs
// Jellyfin Web assets in a background goroutine.
//
// t.TempDir is wrong here: the endpoint returns 202 and its goroutine keeps
// writing into the root after the test body returns, so t.TempDir's cleanup
// trips "directory not empty" on an otherwise passing test.
//
// Removing the directory out from under a running goroutine only moves the
// race, though: a write landing mid-traversal recreates a path RemoveAll has
// already walked past, and the leftovers survive the run. The operation
// records its own terminal state, so cleanup waits for that instead.
func asyncWebInstallRoot(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "jellyfin-web-root-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() {
waitForWebOperation(t, dir)
if err := os.RemoveAll(dir); err != nil {
t.Errorf("removing %s: %v", dir, err)
}
})
return dir
}
// waitForWebOperation blocks until the background install/remove goroutine for
// root has reached a terminal state, or gives up after a bound generous enough
// that only a genuinely stuck operation reaches it.
func waitForWebOperation(t *testing.T, root string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
op := jellycompat.CurrentWebOperation(root)
if op == nil || op.State != jellycompat.WebComponentOperationRunning {
return
}
if time.Now().After(deadline) {
t.Errorf("background %s operation on %s did not finish", op.Kind, root)
return
}
time.Sleep(5 * time.Millisecond)
}
}
+23 -4
View File
@@ -1249,6 +1249,7 @@ func scanRunResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult {
resp.Updated = result.ScanResult.Updated
resp.Unchanged = result.ScanResult.Unchanged
resp.Missing = result.ScanResult.Missing
resp.MissingSkippedProtected = result.ScanResult.MissingSkippedProtected
resp.FilesDeleted = result.ScanResult.FilesDeleted
resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved
resp.ItemsDeleted = result.ScanResult.ItemsDeleted
@@ -2339,13 +2340,18 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
Title string
Year int
ContentType string
Status string
TmdbID string
TvdbID string
ImdbID string
LibraryID int
LibraryName string
}
items := make(map[string]itemInfo, len(contentIDs))
rows, err := h.pool.Query(r.Context(), `
SELECT mi.content_id, mi.title, mi.year, mi.type,
SELECT mi.content_id, mi.title, mi.year, mi.type, COALESCE(mi.status, ''),
COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, ''),
COALESCE(mf_lib.folder_id, 0),
COALESCE(mf_lib.folder_name, '')
FROM media_items mi
@@ -2366,18 +2372,31 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque
defer rows.Close()
for rows.Next() {
var cid, title, ctype, libName string
var cid, title, ctype, status, tmdbID, tvdbID, imdbID, libName string
var year, libID int
if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil {
if err := rows.Scan(&cid, &title, &year, &ctype, &status, &tmdbID, &tvdbID, &imdbID, &libID, &libName); err != nil {
slog.ErrorContext(r.Context(), "scanning item for stale IDs", "component", "api", "error", err)
continue
}
items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName}
items[cid] = itemInfo{
Title: title, Year: year, ContentType: ctype, Status: status,
TmdbID: tmdbID, TvdbID: tvdbID, ImdbID: imdbID,
LibraryID: libID, LibraryName: libName,
}
}
resp := make([]staleMediaIDResponse, 0, len(staleIDs))
for _, s := range staleIDs {
info := items[s.ContentID]
if !metadata.IsActionableStaleProviderID(&models.MediaItem{
ContentID: s.ContentID,
Status: info.Status,
TmdbID: info.TmdbID,
TvdbID: info.TvdbID,
ImdbID: info.ImdbID,
}, s) {
continue
}
resp = append(resp, staleMediaIDResponse{
ContentID: s.ContentID,
LibraryID: info.LibraryID,
@@ -11,7 +11,10 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
evt "github.com/Silo-Server/silo-server/internal/events"
"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"
)
@@ -23,6 +26,7 @@ type libraryLookup interface {
type LibraryPlaybackPrefHandler struct {
storeProvider userstore.UserStoreProvider
libraryLookup libraryLookup
EventsHub *evt.Hub
}
// NewLibraryPlaybackPrefHandler creates a new LibraryPlaybackPrefHandler.
@@ -106,6 +110,11 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
writeError(w, http.StatusBadRequest, "bad_request", "Invalid subtitle_mode")
return
}
sync, err := planLibraryPlaybackSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
@@ -135,7 +144,10 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
}
if !pref.HasAudioLanguage && !pref.HasSubtitleLanguage && !pref.HasSubtitleMode && !pref.HasShowForcedSubtitles {
if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil {
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference")
return
}
@@ -143,8 +155,11 @@ func (h *LibraryPlaybackPrefHandler) HandleSetLibraryPlaybackPref(w http.Respons
return
}
if err := store.UpsertLibraryPlaybackPreference(r.Context(), pref); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set library playback preference")
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, sync, func(tx userstore.PreferenceSettingsWriter) error {
return tx.UpsertLibraryPlaybackPreference(r.Context(), pref)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store library playback preference")
return
}
@@ -169,7 +184,15 @@ func (h *LibraryPlaybackPrefHandler) HandleDeleteLibraryPlaybackPref(w http.Resp
return
}
if err := store.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID); err != nil {
if err := h.applyLibraryPlaybackSettingsSync(r.Context(), store, userID,
profileID, libraryID, []profileSettingSync{
{key: settingskeys.PlaybackAudioLanguage},
{key: settingskeys.PlaybackSubtitleLanguage},
{key: settingskeys.PlaybackSubtitleMode},
{key: settingskeys.PlaybackShowForcedSubtitles},
}, func(tx userstore.PreferenceSettingsWriter) error {
return tx.DeleteLibraryPlaybackPreference(r.Context(), profileID, libraryID)
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete library playback preference")
return
}
@@ -177,6 +200,47 @@ func (h *LibraryPlaybackPrefHandler) HandleDeleteLibraryPlaybackPref(w http.Resp
w.WriteHeader(http.StatusNoContent)
}
func planLibraryPlaybackSettingsSync(req setLibraryPlaybackPrefRequest) ([]profileSettingSync, error) {
out := make([]profileSettingSync, 0, 4)
for _, field := range []struct {
key string
raw *string
}{
{settingskeys.PlaybackAudioLanguage, req.AudioLanguage},
{settingskeys.PlaybackSubtitleLanguage, req.SubtitleLanguage},
{settingskeys.PlaybackSubtitleMode, req.SubtitleMode},
} {
if field.raw == nil {
out = append(out, profileSettingSync{key: field.key})
continue
}
var err error
out, err = appendStringSync(out, field.key, field.raw)
if err != nil {
return nil, err
}
}
forced := profileSettingSync{key: settingskeys.PlaybackShowForcedSubtitles}
if req.ShowForcedSubtitles != nil {
forced.value = json.RawMessage(strconv.FormatBool(*req.ShowForcedSubtitles))
}
return append(out, forced), nil
}
func (h *LibraryPlaybackPrefHandler) applyLibraryPlaybackSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profileID string,
libraryID int,
writes []profileSettingSync,
legacyMutation func(userstore.PreferenceSettingsWriter) error,
) error {
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileLibrary, ProfileID: profileID, LibraryID: libraryID,
}, writes, legacyMutation)
}
func parseLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) {
libraryIDStr := chi.URLParam(r, "library_id")
if libraryIDStr == "" {
@@ -0,0 +1,101 @@
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 routeLibraryPlaybackPref(
t *testing.T,
h *LibraryPlaybackPrefHandler,
method string,
libraryID string,
body []byte,
) *httptest.ResponseRecorder {
t.Helper()
req := valuesRequest(method, "/library-playback-prefs/"+libraryID, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("library_id", libraryID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
if method == http.MethodPut {
h.HandleSetLibraryPlaybackPref(rec, req)
} else {
h.HandleDeleteLibraryPlaybackPref(rec, req)
}
return rec
}
func TestLegacyLibraryPlaybackWritesStayInCanonicalSync(t *testing.T) {
_, store := newValuesTestHandler(t)
handler := NewLibraryPlaybackPrefHandler(testUserStoreProvider{store: store})
rec := routeLibraryPlaybackPref(t, handler, http.MethodPut, "7", []byte(`{
"audio_language":"ja",
"subtitle_language":"de",
"subtitle_mode":"always",
"show_forced_subtitles":false
}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
want := map[string]string{
settingskeys.PlaybackAudioLanguage: `"ja"`,
settingskeys.PlaybackSubtitleLanguage: `"de"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
}
for key, expected := range want {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value == nil {
t.Fatalf("reading canonical %s: value=%+v err=%v", key, value, err)
}
if string(value.Value) != expected {
t.Errorf("%s = %s, want %s", key, value.Value, expected)
}
}
// The legacy PUT replaces the combined row. Omitting three fields clears
// their canonical overrides rather than leaving the backfilled values live.
rec = routeLibraryPlaybackPref(t, handler, http.MethodPut, "7", []byte(`{"audio_language":"fr"}`))
if rec.Code != http.StatusNoContent {
t.Fatalf("replacement PUT = %d: %s", rec.Code, rec.Body.String())
}
for _, key := range []string{
settingskeys.PlaybackSubtitleLanguage,
settingskeys.PlaybackSubtitleMode,
settingskeys.PlaybackShowForcedSubtitles,
} {
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value != nil {
t.Errorf("omitted %s was not cleared: value=%+v err=%v", key, value, err)
}
}
rec = routeLibraryPlaybackPref(t, handler, http.MethodDelete, "7", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage, Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 7,
})
if err != nil || value != nil {
t.Fatalf("DELETE left canonical audio value=%+v err=%v", value, err)
}
}
+141
View File
@@ -0,0 +1,141 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/onboarding"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// OnboardingHandler serves the tour manifest and per-profile progress.
type OnboardingHandler struct {
storeProvider userstore.UserStoreProvider
gates onboarding.Gates
}
// NewOnboardingHandler creates a new OnboardingHandler.
func NewOnboardingHandler(provider userstore.UserStoreProvider, gates onboarding.Gates) *OnboardingHandler {
return &OnboardingHandler{storeProvider: provider, gates: gates}
}
type onboardingStateResponse struct {
TourID string `json:"tour_id"`
LastStep string `json:"last_step,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
SkippedAt string `json:"skipped_at,omitempty"`
// Done is the one bit most clients need: show the tour or not.
Done bool `json:"done"`
}
type onboardingProgressRequest struct {
TourID string `json:"tour_id"`
LastStep string `json:"last_step"`
Completed bool `json:"completed"`
Skipped bool `json:"skipped"`
}
// HandleGetFlow handles GET /onboarding/flow?surface=web|phone|tv.
func (h *OnboardingHandler) HandleGetFlow(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
surface := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("surface")))
switch surface {
case onboarding.SurfaceWeb, onboarding.SurfacePhone, onboarding.SurfaceTV:
case "":
surface = onboarding.SurfaceWeb
default:
writeError(w, http.StatusBadRequest, "bad_request", "surface must be web, phone, or tv")
return
}
isChild := false
if store, err := h.storeProvider.ForUser(r.Context(), userID); err == nil {
if profile, err := store.GetProfile(r.Context(), profileID); err == nil && profile != nil {
isChild = profile.IsChild
}
}
writeJSON(w, http.StatusOK, onboarding.FlowFor(r.Context(), h.gates, surface, isChild))
}
// HandleGetState handles GET /onboarding/state.
func (h *OnboardingHandler) HandleGetState(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
state, err := store.GetOnboardingState(r.Context(), profileID, onboarding.TourID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read onboarding state")
return
}
resp := onboardingStateResponse{TourID: onboarding.TourID}
if state != nil {
resp.LastStep = state.LastStep
resp.CompletedAt = state.CompletedAt
resp.SkippedAt = state.SkippedAt
resp.Done = state.CompletedAt != "" || state.SkippedAt != ""
}
writeJSON(w, http.StatusOK, resp)
}
// HandlePostProgress handles POST /onboarding/progress.
func (h *OnboardingHandler) HandlePostProgress(w http.ResponseWriter, r *http.Request) {
profileID, ok := activeProfileIDFromRequest(w, r)
if !ok {
return
}
userID := apimw.GetUserID(r.Context())
var req onboardingProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
// tour_id is accepted for forward compatibility but only the current
// tour is writable — a stale client can't corrupt a future tour's state.
if req.TourID != "" && req.TourID != onboarding.TourID {
writeError(w, http.StatusConflict, "tour_mismatch", "This tour is no longer current")
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
state := userstore.OnboardingState{
ProfileID: profileID,
TourID: onboarding.TourID,
LastStep: req.LastStep,
}
now := time.Now().UTC().Format(time.RFC3339)
if req.Completed {
state.CompletedAt = now
}
if req.Skipped {
state.SkippedAt = now
}
if err := store.UpsertOnboardingState(r.Context(), state); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save onboarding progress")
return
}
w.WriteHeader(http.StatusNoContent)
}
+143 -26
View File
@@ -30,6 +30,9 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
@@ -459,7 +462,7 @@ type changeAudioResponse struct {
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
}
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
func (resp *changeAudioResponse) setWindowedTimeline(position, origin float64) {
playerStart := max(0, position-origin)
canSeekAnywhere := false
resp.PlayerStartSeconds = &playerStart
@@ -530,10 +533,20 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
if file == nil || file.Duration <= 0 {
return false
}
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
// seeks within the currently exposed window. Out-of-window seeks should
// restart explicitly instead of relying on segment 404s to move FFmpeg.
return !strings.EqualFold(req.TargetCodecVideo, "copy")
return !usesRealTranscodeManifest(req, file)
}
func usesRealTranscodeManifest(req transcodeStartRequest, file *models.MediaFile) bool {
durationSeconds := 0.0
if file != nil {
durationSeconds = float64(file.Duration)
}
// Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's
// real manifest so the player only seeks within the currently exposed
// window. Out-of-window seeks should restart explicitly instead of relying
// on segment 404s to move FFmpeg.
return strings.EqualFold(req.TargetCodecVideo, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, req.SegmentDuration)
}
func buildTranscodeStartResponse(
@@ -1211,6 +1224,34 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
return lang
}
// resolvedProfileAudioLanguage returns the effective playback.audio_language
// for the profile with no content context, resolved through the settings
// contract — the canonical replacement for reading the legacy
// user_profiles.language column, matching catalog's detail resolution. It may
// return playback.OriginalLanguageSentinel, which the caller resolves to a
// concrete language. Returns "" when nothing is stored: the contract default
// is null, "no preference".
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
contract, err := settingscontract.Load()
if err != nil {
return ""
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.PlaybackAudioLanguage}, nil)
if err != nil || len(resolved) == 0 {
return ""
}
var language string
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
}
return strings.TrimSpace(language)
}
func (h *PlaybackHandler) restoreSessionProgress(
ctx context.Context,
session *playback.Session,
@@ -1715,9 +1756,7 @@ func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *ht
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
}
if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil {
preferredLang = profile.Language
}
preferredLang = resolvedProfileAudioLanguage(r.Context(), store, profileID)
// Resolve library override (if no series sticky pref exists).
var libraryAudioLang string
@@ -2093,17 +2132,26 @@ func buildSubtitleURLs(
Label: dl.ReleaseName + " (" + dl.Provider + ")",
Source: "downloaded",
HearingImpaired: dl.HearingImpaired,
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
URL: downloadedSubtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID, dl.ID),
})
}
return urls
}
const downloadedSubtitleIDParam = "downloaded_subtitle_id"
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
}
// downloadedSubtitleStreamURL binds a downloaded subtitle URL to its stable
// database identity while preserving the combined track index for clients.
func downloadedSubtitleStreamURL(sessionID string, trackIndex int, codec string, fileID, downloadedID int) string {
return subtitleStreamURL(sessionID, trackIndex, codec, fileID) +
"&" + downloadedSubtitleIDParam + "=" + strconv.Itoa(downloadedID)
}
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
if !playback.IsASS(codec) {
return ""
@@ -2340,8 +2388,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
restartStreamOriginSeconds := 0.0
restartCopyAnchorResolved := false
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
restartManifestRequest := transcodeStartRequest{
TargetCodecVideo: targetVideoCodec,
SegmentDuration: restartSegmentDuration,
}
legacyWindowedRestart := session.PlayMethod == playback.PlayTranscode &&
isLegacyTransportSession(session) && usesRealTranscodeManifest(restartManifestRequest, file)
if legacyWindowedRestart {
restartStreamOriginSeconds = restartSeekSeconds
}
legacyCopyRestart := legacyWindowedRestart && strings.EqualFold(targetVideoCodec, "copy")
if legacyCopyRestart {
restartCopyAnchorResolved = true
if req.Position > 0 {
@@ -2609,8 +2665,12 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
}
// A v3 DV strip remux carries its bitstream filter in the
// durable session route; dropping it here would hand the node
// a DV7 copy recipe that leaves dangling RPUs.
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") {
// a DV7 copy recipe that leaves dangling RPUs. Sources that
// fail the RPU probe are the exception — for them the filter
// rejects every packet, so re-adding it on an audio switch
// would hang a session that was playing a moment ago.
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") &&
playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
}
@@ -2746,8 +2806,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
}
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
}
if legacyCopyRestart {
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
if legacyWindowedRestart {
resp.setWindowedTimeline(req.Position, restartStreamOriginSeconds)
}
h.syncSessionsNow(r.Context(), "audio_change")
@@ -3100,10 +3160,21 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
// for "copy" has no way to know the source needs the strip. Derived after
// the burn-in guard above so a copy request it rewrites to h264 never carries
// a copy-only bitstream filter.
//
// Gated on the per-source probe for the same reason the planner is: a
// source whose RPU ffmpeg cannot parse turns the filter into a per-packet
// rejection that never produces a segment, and this endpoint would
// otherwise put it back on every quality change, seek and burn-in restart
// of a session the planner had already routed away from it.
videoBitstreamFilter := ""
if strings.EqualFold(req.TargetCodecVideo, "copy") &&
(session.RemuxDVMode == playback.RemuxDVStripToHDR10V3 || file.PrimaryDVProfile() == 7) {
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
if playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
} else {
slog.WarnContext(r.Context(), "restart dropped the dolby vision rpu strip: source cannot be stripped",
"component", "api", "playback_session_id", req.SessionID, "file_id", file.ID)
}
}
// The request-level permission check above intentionally runs before the
@@ -3134,6 +3205,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
switchedFileID = &alt.ID
}
}
if !videoCopy {
req.TargetResolution = clampEncodedTargetResolution(req.TargetResolution, file.Resolution)
}
if requestedFile != nil && file != nil && requestedFile.ID != file.ID {
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api",
@@ -3184,6 +3258,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
streamOriginSeconds := 0.0
if usesRealTranscodeManifest(req, file) {
streamOriginSeconds = transportSeekSeconds
}
if videoCopy {
streamOriginSeconds = req.SeekSeconds
if req.SeekSeconds > 0 {
@@ -3921,20 +3998,60 @@ func (h *PlaybackHandler) findAlternateFile(ctx context.Context, source *models.
return candidates[0], nil
}
const (
transcodeResolution2160p = "2160p"
transcodeResolution1080p = "1080p"
transcodeResolution720p = "720p"
transcodeResolution480p = "480p"
transcodeResolution420p = "420p"
transcodeResolution328p = "328p"
)
// resolutionRank returns a numeric rank for resolution sorting.
func resolutionRank(res string) int {
switch res {
case "2160p":
return 4
case "1080p":
return 3
case "720p":
return 2
case "480p":
return 1
case "328p":
height, known := transcodeResolutionHeight(res)
if !known {
return 0
}
switch {
case height >= 2160:
return 4
case height >= 1080:
return 3
case height >= 720:
return 2
case height >= 480:
return 1
default:
return 0
}
}
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
return requestedResolution
}
return sourceResolution
}
func transcodeResolutionHeight(resolution string) (int, bool) {
switch resolution {
case transcodeResolution2160p:
return 2160, true
case transcodeResolution1080p:
return 1080, true
case transcodeResolution720p:
return 720, true
case transcodeResolution480p:
return 480, true
case transcodeResolution420p:
return 420, true
case transcodeResolution328p:
return 328, true
default:
return 0, false
}
}
+70 -5
View File
@@ -74,6 +74,7 @@ type playbackSessionRow struct {
AudioDecision string `json:"audio_decision,omitempty"`
EffectivePlayMethod string `json:"effective_play_method,omitempty"`
IsJellyfinClient bool `json:"is_jellyfin_client,omitempty"`
CompatOrigin bool `json:"-"`
}
// playbackSessionsCapabilitiesResponse advertises the additive fields of the
@@ -199,7 +200,8 @@ func (l *PlaybackSessionsLoader) Load(
mf.audio_channels,
COALESCE(mf.audio_tracks::text, '[]'),
COALESCE(requested_mf.codec_video, ''),
COALESCE(requested_mf.resolution, '')
COALESCE(requested_mf.resolution, ''),
COALESCE(s.compat_origin, FALSE)
FROM playback_sessions_sync s
LEFT JOIN users u ON u.id = s.user_id
LEFT JOIN media_files mf ON mf.id = s.media_file_id
@@ -241,6 +243,7 @@ func (l *PlaybackSessionsLoader) Load(
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
&s.CompatOrigin,
); err != nil {
return nil, fmt.Errorf("scanning playback session: %w", err)
}
@@ -275,7 +278,7 @@ func enrichPlaybackSessionRow(row *playbackSessionRow, audioTracksJSON []byte) {
row.VideoDecision, row.AudioDecision = sessionComponentDecision(row.PlayMethod, row.TranscodeAudio, row.TargetVideoCodec)
row.EffectivePlayMethod = effectivePlayMethod(row.VideoDecision, row.AudioDecision)
row.IsJellyfinClient = isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
row.IsJellyfinClient = row.CompatOrigin || isJellyfinEcosystemClient(row.ClientName, row.ClientUserAgent)
var audioTracks []models.AudioTrack
if len(audioTracksJSON) > 0 {
@@ -418,9 +421,8 @@ var jellyfinClientTokens = []string{
}
// isJellyfinEcosystemClient reports whether the session's client metadata
// matches a known Jellyfin-ecosystem client. This is a heuristic: an
// unrecognized fork simply gets no JF pill (cosmetic). Stamping a compat-origin
// flag on the session at creation would be exact and is the eventual fix.
// matches a known Jellyfin-ecosystem client. This heuristic remains a fallback
// for rows created before compat-origin identity was persisted.
func isJellyfinEcosystemClient(clientName, userAgent string) bool {
for _, value := range []string{clientName, userAgent} {
value = strings.ToLower(strings.TrimSpace(value))
@@ -508,10 +510,73 @@ func playbackClientDisplayName(name, version, userAgent string) string {
case strings.Contains(lower, "python-requests"):
return "Python requests"
default:
if label := androidDeviceLabel(userAgent); label != "" {
return label
}
return firstUserAgentProduct(userAgent)
}
}
// knownAndroidDeviceLabels maps Android / Fire OS build model codes to friendly
// product names for the admin session view. Keys are uppercased so the lookup is
// case-insensitive. This only affects the displayed label — the session still
// stores the raw model code in its user agent.
var knownAndroidDeviceLabels = map[string]string{
"AFTKRT": "Fire TV Stick 4K Max",
"AFTMM": "Fire TV Stick 4K",
"AFTKM": "Fire TV Stick 4K (2nd Gen)",
"AFTKA": "Fire TV Stick 4K Max (1st Gen)",
"AFTSSS": "Fire TV Stick (3rd Gen)",
"AFTSS": "Fire TV Stick Lite (1st Gen)",
"AFTT": "Fire TV Stick (2nd Gen)",
"AFTB": "Fire TV (1st Gen)",
"AFTS": "Fire TV (2nd Gen)",
"AFTN": "Fire TV (3rd Gen)",
"AFTR": "Fire TV Cube (2nd Gen)",
"AFTA": "Fire TV Cube (1st Gen)",
"SHIELD ANDROID TV": "NVIDIA Shield",
}
// androidDeviceLabel derives a friendly device label from a bare Android / Fire OS
// user agent of the form "... (Linux; U; Android <ver>; <MODEL> Build/<build>)".
// The model is the whole segment between the last ';' and 'Build/', so multi-word
// models ("Pixel 7", "SHIELD Android TV") survive intact. Known model codes map to
// a product name; anything else falls back to "Android · <MODEL>" rather than the
// uninformative "Dalvik". Returns "" when no model can be parsed.
func androidDeviceLabel(userAgent string) string {
buildIndex := strings.Index(userAgent, "Build/")
if buildIndex < 0 {
return ""
}
prefix := userAgent[:buildIndex]
separator := strings.LastIndex(prefix, ";")
if separator < 0 {
return ""
}
hasAndroidPlatform := false
for _, segment := range strings.Split(prefix[:separator], ";") {
segment = strings.TrimSpace(strings.Trim(segment, "()"))
if strings.HasPrefix(strings.ToLower(segment), "android ") {
hasAndroidPlatform = true
break
}
}
if !hasAndroidPlatform {
return ""
}
model := prefix[separator+1:]
model = strings.TrimSpace(strings.Trim(strings.TrimSpace(model), "();"))
if model == "" {
return ""
}
if label, ok := knownAndroidDeviceLabels[strings.ToUpper(model)]; ok {
return label
}
return "Android · " + model
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
@@ -103,3 +103,126 @@ func TestIsJellyfinEcosystemClient(t *testing.T) {
})
}
}
func TestPlaybackClientDisplayNameAndroidDevices(t *testing.T) {
const curlClientLabel = "curl"
cases := []struct {
name string
userAgent string
want string
}{
{
name: "fire tv stick 4k max",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKRT Build/RS8180.3729N)",
want: "Fire TV Stick 4K Max",
},
{
name: "fire tv stick 4k",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTMM Build/PS7279)",
want: "Fire TV Stick 4K",
},
{
name: "fire tv stick 4k second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; AFTKM Build/RS8139)",
want: "Fire TV Stick 4K (2nd Gen)",
},
{
name: "fire tv stick 4k max first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTKA Build/PS7646)",
want: "Fire TV Stick 4K Max (1st Gen)",
},
{
name: "fire tv stick third generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSSS Build/PS7279)",
want: "Fire TV Stick (3rd Gen)",
},
{
name: "fire tv stick lite first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTSS Build/PS7279)",
want: "Fire TV Stick Lite (1st Gen)",
},
{
name: "fire tv stick second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTT Build/LVY48F)",
want: "Fire TV Stick (2nd Gen)",
},
{
name: "fire tv first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 4.2; AFTB Build/JDQ39)",
want: "Fire TV (1st Gen)",
},
{
name: "fire tv second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 5.1; AFTS Build/LVY48F)",
want: "Fire TV (2nd Gen)",
},
{
name: "fire tv third generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTN Build/NS6265)",
want: "Fire TV (3rd Gen)",
},
{
name: "fire tv cube second generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 9; AFTR Build/PS7646)",
want: "Fire TV Cube (2nd Gen)",
},
{
name: "fire tv cube first generation",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 7.1; AFTA Build/NS6265)",
want: "Fire TV Cube (1st Gen)",
},
{
name: "unmapped multi-word android model preserved",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)",
want: "Android · Pixel 7",
},
{
name: "shield model",
userAgent: "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A)",
want: "NVIDIA Shield",
},
{
name: "chrome remains browser label",
userAgent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
want: "Chrome 120",
},
{
name: "non-android build user agent keeps existing fallback",
userAgent: "curl/8.0 (Linux; Device Build/42)",
want: curlClientLabel,
},
{
name: "explicit client fallback wins over android device model",
userAgent: "curl/8.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A)",
want: curlClientLabel,
},
{
name: "android substring is not an android platform token",
userAgent: "Dalvik/2.1.0 (Linux; U; NotAndroid 13; Pixel 7 Build/TQ3A)",
want: "Dalvik",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbackClientDisplayName("", "", tc.userAgent)
if got != tc.want {
t.Fatalf("playbackClientDisplayName() = %q, want %q", got, tc.want)
}
})
}
}
func TestEnrichPlaybackSessionRowUsesCompatOrigin(t *testing.T) {
row := playbackSessionRow{
ClientName: "Unrecognized Client",
ClientUserAgent: "Dalvik/2.1.0",
CompatOrigin: true,
}
enrichPlaybackSessionRow(&row, nil)
if !row.IsJellyfinClient {
t.Fatal("compat-origin session must be marked as a Jellyfin client")
}
}
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func TestSubtitleURLExt(t *testing.T) {
@@ -26,6 +27,17 @@ func TestSubtitleURLExt(t *testing.T) {
}
}
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
file := &models.MediaFile{ID: 42}
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
if len(urls) != 1 {
t.Fatalf("downloaded subtitle URLs = %#v", urls)
}
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
}
}
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
file := &models.MediaFile{
ID: 42,
+425 -4
View File
@@ -28,6 +28,8 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/userdb"
@@ -614,6 +616,31 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
if encodedResp.TimelineOffsetSeconds != 0 {
t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds)
}
longEncodedResp := buildTranscodeStartResponse(
transcodeStartRequest{
SessionID: "session-long-encoded",
SeekSeconds: 18.261,
TargetCodecVideo: "h264",
SegmentDuration: 2,
},
&models.MediaFile{Duration: 1_000_000},
nil,
"/playback/transcode/session-long-encoded/master.m3u8",
18,
)
if longEncodedResp.CanSeekAnywhere {
t.Fatal("long encoded response should require explicit restart seeks")
}
if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 {
t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds)
}
if longEncodedResp.StreamOriginSeconds != 18 {
t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds)
}
if longEncodedResp.TimelineOffsetSeconds != 18 {
t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds)
}
}
func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) {
@@ -693,6 +720,86 @@ func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *
}
}
func TestHandleStartPlayback_AudioLanguageResolvesCanonically(t *testing.T) {
// The default audio track comes from the canonical playback.audio_language
// value resolved through the settings contract, not from the legacy
// user_profiles.language column. The column always carries the language of
// a different track than the canonical answer, so a regression to reading
// it flips the selected index.
newFile := func(t *testing.T) *models.MediaFile {
return &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
Duration: 3600,
AudioTracks: []models.AudioTrack{
{Language: "eng", Codec: "aac", Default: true},
{Language: "jpn", Codec: "aac"},
},
}
}
setLegacyLanguage := func(t *testing.T, store userstore.UserStore, language string) {
t.Helper()
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
Language: &language,
}); err != nil {
t.Fatalf("seed legacy language column: %v", err)
}
}
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) playbackSessionResponse {
t.Helper()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest("POST", "/api/v1/playback/start",
strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackSessionResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
t.Run("canonical value wins over legacy column", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "eng")
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"ja"`)); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
resp := startPlayback(t, store, newFile(t))
if resp.AudioTrackIndex != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", resp.AudioTrackIndex)
}
})
t.Run("legacy column alone no longer selects a track", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "jpn")
resp := startPlayback(t, store, newFile(t))
// No canonical value stored: the contract default is "no preference",
// so selection falls to the file's default track, not the column's.
if resp.AudioTrackIndex != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", resp.AudioTrackIndex)
}
})
}
func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) {
store := newPlaybackTestStore(t)
file := &models.MediaFile{
@@ -1343,7 +1450,7 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
transcodeReq := httptest.NewRequest(
"POST",
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"2160p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":10000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
)
transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext())
@@ -1359,6 +1466,12 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
if remoteStartReq.AudioTrackIndex != 1 {
t.Fatalf("remote audio_track_index = %d, want 1", remoteStartReq.AudioTrackIndex)
}
if remoteStartReq.TargetResolution != transcodeResolution1080p {
t.Fatalf("remote target resolution = %q, want 1080p", remoteStartReq.TargetResolution)
}
if remoteStartReq.TargetBitrateKbps != 10000 {
t.Fatalf("remote target bitrate = %d, want unchanged 10000", remoteStartReq.TargetBitrateKbps)
}
session, err := sessionMgr.GetSession(startResp.SessionID)
if err != nil {
@@ -1370,6 +1483,12 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
if session.BasePlayMethod != playback.PlayRemux {
t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux)
}
if session.TargetResolution != transcodeResolution1080p {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
if session.TargetBitrateKbps != 10000 {
t.Fatalf("session target bitrate = %d, want unchanged 10000", session.TargetBitrateKbps)
}
}
// newRemoteAudioSwitchSession builds a session that is already an offloaded
@@ -1520,7 +1639,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
CodecAudio: "aac",
Container: "mkv",
Bitrate: 8000,
Duration: 3600,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{
{Codec: "aac", Default: true},
{Codec: "ac3"},
@@ -1609,6 +1728,9 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
if remoteStartReq.SeekSeconds != 120 {
t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds)
}
if remoteStartReq.StreamOriginSeconds != 120 {
t.Fatalf("remote StreamOriginSeconds = %v, want aligned 120", remoteStartReq.StreamOriginSeconds)
}
if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" {
t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo)
}
@@ -1645,6 +1767,15 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
if claims.TargetBitrateKbps != 2000 {
t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps)
}
if claims.StreamOriginSeconds != 120 {
t.Fatalf("token StreamOriginSeconds = %v, want aligned 120", claims.StreamOriginSeconds)
}
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
t.Fatalf("remote long encoded response timeline = %+v", resp)
}
if claims.SeekSeconds != 120 {
t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds)
}
@@ -2010,7 +2141,7 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
transcodeReq := httptest.NewRequest(
"POST",
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"2160p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":10000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
)
transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext())
@@ -2030,6 +2161,114 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
if got := transcodeSession.Opts().AudioTrackIndex; got != 1 {
t.Fatalf("local transcode audio track index = %d, want 1", got)
}
if got := transcodeSession.Opts().TargetResolution; got != transcodeResolution1080p {
t.Fatalf("local target resolution = %q, want 1080p", got)
}
if got := transcodeSession.Opts().TargetBitrateKbps; got != 10000 {
t.Fatalf("local target bitrate = %d, want unchanged 10000", got)
}
session, err := sessionMgr.GetSession(startResp.SessionID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if session.TargetResolution != transcodeResolution1080p {
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
}
if session.TargetBitrateKbps != 10000 {
t.Fatalf("session target bitrate = %d, want unchanged 10000", session.TargetBitrateKbps)
}
}
func TestHandleChangeAudioTrack_LocalLongEncodedPreservesWindowedTimeline(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie-local-long-encoded.mkv"),
Resolution: "1080p",
CodecVideo: "hevc",
CodecAudio: "ac3",
Container: "mkv",
Bitrate: 8000,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}},
}
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.JWTSecret = "test-secret"
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
startReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1}`),
).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartTranscode(startRR, startReq)
if startRR.Code != http.StatusAccepted {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
predecessor := handler.tm.GetTranscodeSession(session.ID)
if predecessor == nil {
t.Fatal("expected local long encoded session")
}
t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") })
changeReq := httptest.NewRequest(
http.MethodPatch,
"/api/v1/playback/"+session.ID+"/audio",
strings.NewReader(`{"audio_track_index":1,"position":121.5}`),
).WithContext(newAuthorizedPlaybackContext())
changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID)
changeRR := httptest.NewRecorder()
handler.HandleChangeAudioTrack(changeRR, changeReq)
if changeRR.Code != http.StatusOK {
t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String())
}
successor := handler.tm.GetTranscodeSession(session.ID)
if successor == nil || successor == predecessor {
t.Fatal("audio switch did not publish a prepared successor")
}
if predecessor.IsRunning() {
t.Fatal("audio switch predecessor is still running after commit")
}
opts := successor.Opts()
if opts.TargetCodecVideo != "h264" || opts.SeekSeconds != 120 ||
opts.StreamOriginSeconds != 120 || opts.CopySeekAnchorResolved ||
opts.StartSegmentNumber != 60 || opts.AudioTrackIndex != 1 {
t.Fatalf("local long encoded restart opts = %+v", opts)
}
var resp changeAudioResponse
if err := json.NewDecoder(changeRR.Body).Decode(&resp); err != nil {
t.Fatalf("decode change response: %v", err)
}
manifestURL, err := url.Parse(resp.StreamURL)
if err != nil {
t.Fatalf("parse stream URL: %v", err)
}
claims, err := streamtoken.Verify(manifestURL.Query().Get(streamTokenParam), handler.JWTSecret)
if err != nil {
t.Fatalf("verify stream token: %v", err)
}
if claims.TargetCodec != "h264" || claims.SeekSeconds != 120 ||
claims.StreamOriginSeconds != 120 || claims.CopySeekAnchorResolved ||
claims.StartSegmentNumber != 60 || claims.AudioTrackIndex != 1 {
t.Fatalf("local long encoded reconstruction claims = %+v", claims)
}
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
t.Fatalf("local long encoded response timeline = %+v", resp)
}
}
func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) {
@@ -2285,6 +2524,61 @@ func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) {
}
}
func TestHandleStartTranscode_LongEncodedUsesAlignedRealManifestOrigin(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie-long-encoded.mkv"),
Resolution: "1080p",
CodecVideo: "hevc",
CodecAudio: "dts",
Container: "mkv",
Bitrate: 25000,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}},
}
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
transcodeReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
).WithContext(newAuthorizedPlaybackContext())
transcodeRR := httptest.NewRecorder()
handler.HandleStartTranscode(transcodeRR, transcodeReq)
if transcodeRR.Code != http.StatusAccepted {
t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String())
}
var response transcodeStartResponse
if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil {
t.Fatalf("decode transcode response: %v", err)
}
if math.Abs(response.PlayerStartSeconds-0.261) > 0.0001 || response.StreamOriginSeconds != 18 ||
response.TimelineOffsetSeconds != 18 || response.CanSeekAnywhere {
t.Fatalf("long encoded response timeline = %+v", response)
}
transcodeSession := handler.tm.GetTranscodeSession(session.ID)
if transcodeSession == nil {
t.Fatal("expected local transcode session")
}
t.Cleanup(func() { _ = transcodeSession.Close() })
opts := transcodeSession.Opts()
if opts.SeekSeconds != 18 || opts.StreamOriginSeconds != 18 || opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 9 {
t.Fatalf("long encoded seek recipe = seek %v origin %v copy anchor %v segment %d", opts.SeekSeconds, opts.StreamOriginSeconds, opts.CopySeekAnchorResolved, opts.StartSegmentNumber)
}
}
func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
@@ -2400,7 +2694,7 @@ func TestHandleStartTranscode_SeekedCopyPreservedOnRemoteNode(t *testing.T) {
transcodeReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"2160p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
).WithContext(newAuthorizedPlaybackContext())
transcodeRR := httptest.NewRecorder()
handler.HandleStartTranscode(transcodeRR, transcodeReq)
@@ -2411,6 +2705,9 @@ func TestHandleStartTranscode_SeekedCopyPreservedOnRemoteNode(t *testing.T) {
remoteStartReq.StreamOriginSeconds != 16 || !remoteStartReq.CopySeekAnchorResolved || remoteStartReq.StartSegmentNumber != 8 {
t.Fatalf("remote copy recipe = codec %q seek %v origin %v segment %d", remoteStartReq.TargetCodecVideo, remoteStartReq.SeekSeconds, remoteStartReq.StreamOriginSeconds, remoteStartReq.StartSegmentNumber)
}
if remoteStartReq.TargetResolution != transcodeResolution2160p {
t.Fatalf("remote copy target resolution = %q, want unchanged 2160p", remoteStartReq.TargetResolution)
}
if !strings.HasPrefix(remoteStartReq.SessionID, session.ID+legacyTransportMarker) {
t.Fatalf("remote SessionID = %q, want legacy transport for %q", remoteStartReq.SessionID, session.ID)
}
@@ -2904,6 +3201,9 @@ func TestHandleStartTranscode_SeekedCopyAllowedWhenVideoTranscodingDisabled(t *t
if got := transcodeSession.Opts().TargetCodecVideo; got != "copy" {
t.Fatalf("target video codec = %q, want copy", got)
}
if got := transcodeSession.Opts().TargetResolution; got != "" {
t.Fatalf("copy-video target resolution = %q, want existing empty recipe", got)
}
}
func TestHandleStartTranscode_SubtitleBurnInRechecksVideoTranscodePermission(t *testing.T) {
@@ -3369,6 +3669,127 @@ func TestFindAlternateFile_DoesNotCrossEdition(t *testing.T) {
}
}
func TestClampEncodedTargetResolution(t *testing.T) {
tests := []struct {
name string
requested string
source string
want string
}{
{
name: "clamps 2160p to 1080p",
requested: transcodeResolution2160p,
source: transcodeResolution1080p,
want: transcodeResolution1080p,
},
{
name: "keeps lower target",
requested: transcodeResolution720p,
source: transcodeResolution1080p,
want: transcodeResolution720p,
},
{
name: "keeps equal target",
requested: transcodeResolution1080p,
source: transcodeResolution1080p,
want: transcodeResolution1080p,
},
{
name: "keeps empty target",
requested: "",
source: transcodeResolution1080p,
want: "",
},
{
name: "keeps unknown target",
requested: "unrecognized-target",
source: transcodeResolution1080p,
want: "unrecognized-target",
},
{
name: "keeps target for unknown source",
requested: transcodeResolution2160p,
source: "native",
want: transcodeResolution2160p,
},
{
name: "supports low tiers",
requested: transcodeResolution480p,
source: transcodeResolution420p,
want: transcodeResolution420p,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := clampEncodedTargetResolution(tt.requested, tt.source); got != tt.want {
t.Fatalf(
"clampEncodedTargetResolution(%q, %q) = %q, want %q",
tt.requested,
tt.source,
got,
tt.want,
)
}
})
}
}
func TestTranscodeResolutionHeight(t *testing.T) {
tests := []struct {
resolution string
wantHeight int
wantKnown bool
}{
{resolution: transcodeResolution2160p, wantHeight: 2160, wantKnown: true},
{resolution: transcodeResolution1080p, wantHeight: 1080, wantKnown: true},
{resolution: transcodeResolution720p, wantHeight: 720, wantKnown: true},
{resolution: transcodeResolution480p, wantHeight: 480, wantKnown: true},
{resolution: transcodeResolution420p, wantHeight: 420, wantKnown: true},
{resolution: transcodeResolution328p, wantHeight: 328, wantKnown: true},
{resolution: "unrecognized-tier", wantHeight: 0, wantKnown: false},
}
for _, tt := range tests {
t.Run(tt.resolution, func(t *testing.T) {
gotHeight, gotKnown := transcodeResolutionHeight(tt.resolution)
if gotHeight != tt.wantHeight || gotKnown != tt.wantKnown {
t.Fatalf(
"transcodeResolutionHeight(%q) = (%d, %t), want (%d, %t)",
tt.resolution,
gotHeight,
gotKnown,
tt.wantHeight,
tt.wantKnown,
)
}
})
}
}
func TestResolutionRank(t *testing.T) {
tests := []struct {
resolution string
want int
}{
{resolution: transcodeResolution2160p, want: 4},
{resolution: transcodeResolution1080p, want: 3},
{resolution: transcodeResolution720p, want: 2},
{resolution: transcodeResolution480p, want: 1},
{resolution: transcodeResolution420p, want: 0},
{resolution: transcodeResolution328p, want: 0},
{resolution: "unrecognized-tier", want: 0},
}
for _, tt := range tests {
t.Run(tt.resolution, func(t *testing.T) {
if got := resolutionRank(tt.resolution); got != tt.want {
t.Fatalf("resolutionRank(%q) = %d, want %d", tt.resolution, got, tt.want)
}
})
}
}
func TestAlignedSeekSeconds(t *testing.T) {
tests := []struct {
name string
+349 -69
View File
@@ -35,6 +35,11 @@ const (
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
seekRestorationPlayerV3 = "player_position"
// Failed capability fetches are memoized briefly so an unreachable node
// costs one timeout per window instead of one per planning request.
v3NodeCapabilityErrorTTL = 15 * time.Second
@@ -44,6 +49,8 @@ const (
v3NodeCapabilityPlanTimeout = 3 * time.Second
)
var errSubtitleStoreUnavailableV3 = errors.New("subtitle store unavailable")
type v3NodeCapabilityCache struct {
transformations []playback.TransformationV3
err error
@@ -67,6 +74,19 @@ type transportErrorV3 struct {
cause error
}
func subtitleArtifactErrorV3(message string, cause error) *transportErrorV3 {
return &transportErrorV3{
reason: subtitleUnavailableReasonV3,
message: message,
retryable: errors.Is(cause, errSubtitleStoreUnavailableV3),
cause: cause,
}
}
func wrapSubtitleStoreErrorV3(err error) error {
return fmt.Errorf("%w: %w", errSubtitleStoreUnavailableV3, err)
}
type v3ReplanLock struct {
mu sync.Mutex
refs int
@@ -425,7 +445,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile,
AudioTrackIndex: audioIndex, Settings: settings,
Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(),
Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(),
AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile),
})
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && shouldTryAlternateFileV3(req.QualityPreference) {
@@ -440,7 +460,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
writePlaybackFilePreflightError(w, err)
return
}
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: req, RequestedFile: requestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
}
if result.Terminal != nil {
@@ -525,19 +545,24 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err}
}
result.Plan.SessionID = session.ID
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
if frozenErr != nil {
abort()
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
}
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
if transportErr != nil {
abort()
return playback.DecisionResponseV3{}, transportErr
}
result.Plan.Stream.URL = transport.url
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil {
transport.rollback()
abort()
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
transport.rollback()
abort()
@@ -708,9 +733,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
videoCodec = "copy"
}
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
unlock := h.tm.LockSessionLifecycle(session.ID)
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink})
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink})
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
@@ -765,8 +791,9 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
videoCodec = "copy"
}
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true}
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, RequireReady: true}
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
if err != nil {
// A timeout can fire after the node actually started the job; the
@@ -812,6 +839,16 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}}, nil
}
func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) (string, float64) {
if result.FrozenSourceMetadata != nil {
return result.FrozenSourceMetadata.VideoCodec, result.FrozenSourceMetadata.DurationSeconds
}
if file == nil {
return "", 0
}
return file.CodecVideo, float64(file.Duration)
}
func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState {
state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn}
if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) {
@@ -842,16 +879,38 @@ func transportGenerationV3(sessionID, planID string) string {
return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8]
}
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error {
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int, recipe *playback.ExecutableRecipeV3) error {
if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) {
return nil
}
if recipe != nil && recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
return errors.New("the frozen downloaded subtitle is unavailable")
}
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
if err != nil {
return wrapSubtitleStoreErrorV3(err)
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
return errors.New("the frozen downloaded subtitle is unavailable for the selected media file")
}
url := downloadedSubtitleStreamURL(sessionID, selectedIndex, string(downloaded.Format), file.ID, downloaded.ID)
format := strings.ToLower(string(downloaded.Format))
mime := subtitleMIMEV3(format)
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
format = subtitleFormatVTTV3
mime = subtitleMIMEVTTV3
url = forceSubtitleExtensionV3(url, ".vtt")
}
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
return nil
}
var downloaded []subtitles.DownloadedSubtitle
if h.SubtitleRepo != nil {
var err error
downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID)
if err != nil {
return err
return wrapSubtitleStoreErrorV3(err)
}
}
for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) {
@@ -862,8 +921,8 @@ func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionI
mime := subtitleMIMEV3(format)
url := value.URL
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
format = "vtt"
mime = "text/vtt"
format = subtitleFormatVTTV3
mime = subtitleMIMEVTTV3
url = forceSubtitleExtensionV3(value.URL, ".vtt")
}
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
@@ -883,7 +942,7 @@ func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, fil
base := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded))
for index, value := range downloaded {
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"})
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded", DownloadedSubtitleID: value.ID})
}
return result
}
@@ -1182,7 +1241,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
cause: err,
}
}
if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) {
seekDuration := float64(effectiveFile.Duration)
if seekReanchor && record.FrozenRecipe.ValidFor(record.CurrentPlan) {
seekDuration = record.FrozenRecipe.SourceDurationSeconds
}
if seekScopedRecovery && seekDuration > 0 && req.PositionSeconds > seekDuration {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "invalid_seek_position",
message: "The requested seek position is beyond the end of the selected media source.",
@@ -1191,9 +1254,12 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
if _, err := start.NormalizeAndValidate(); err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()}
}
audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
if err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
audioIndex := 0
if !seekReanchor {
audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
if err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
}
}
attemptedKeys := []string(nil)
if !intentChange && !seekReanchor {
@@ -1212,7 +1278,23 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
attemptedKeys = append(attemptedKeys, currentKey)
}
}
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
var result playback.PlannerResultV3
if seekReanchor {
if err := h.validateFrozenSubtitleIdentityV3(r.Context(), effectiveFile, record.FrozenRecipe); err != nil {
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("The selected subtitle is no longer available at its frozen route.", err)
}
var frozenErr error
result, frozenErr = frozenSeekReanchorResultV3(record, req.PositionSeconds, time.Now())
if frozenErr != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "seek_reanchor_recipe_unavailable",
message: "The active playback recipe cannot be reopened; start a new playback attempt.",
retryable: true,
}
}
} else {
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
@@ -1222,7 +1304,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
if err := preflightPlaybackFile(r.Context(), alternate, h.MissingMarker, h.EventsHub); err == nil {
effectiveFile = alternate
audioIndex = remappedAudio
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
}
}
@@ -1252,17 +1334,31 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
}
}
result.Plan.SessionID = session.ID
artifactRecipe := record.FrozenRecipe
if !seekReanchor {
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
if frozenErr != nil {
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
}
artifactRecipe = frozenRecipe
}
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
if transportErr != nil {
return playback.DecisionResponseV3{}, *record, nil, transportErr
}
result.Plan.Stream.URL = transport.url
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil {
transport.rollback()
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
if seekReanchor {
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
changedFields := seekReanchorIdentityChangesV3(record, result.Plan)
slog.ErrorContext(r.Context(), "protocol v3 seek reanchor changed route identity",
"session", record.SessionID,
"playback_attempt_id", record.PlaybackAttemptID,
"changed_fields", changedFields,
)
transport.rollback()
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "seek_reanchor_route_changed",
@@ -1274,6 +1370,15 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
updated := *record
updated.CurrentPlanID = result.Plan.PlanID
updated.CurrentPlan = *result.Plan
// A seek reanchor replays the durable recipe verbatim (updated already
// carries it); re-freezing from live inventory could only re-introduce
// the drift this path exists to exclude. Every other replan just accepted
// a freshly planned route and must freeze its recipe — loudly, because a
// recipe with a silently missing subtitle identity would disable drift
// detection for every later seek on this attempt.
if !seekReanchor {
updated.FrozenRecipe = artifactRecipe
}
updated.NormalizedRequest = start
updated.EffectiveMediaFileID = effectiveFile.ID
updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL)
@@ -1314,6 +1419,133 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
return response, updated, &transport, nil
}
func frozenSeekReanchorResultV3(record *playback.AttemptRecordV3, position float64, now time.Time) (playback.PlannerResultV3, error) {
if record == nil || !record.FrozenRecipe.ValidFor(record.CurrentPlan) {
return playback.PlannerResultV3{}, errors.New("the active playback recipe is unavailable")
}
plan := record.CurrentPlan
plan.ExpiresAt = playback.NewPlanExpiryV3(now)
plan.Timeline = playback.TimelineV3{
SourceStartSeconds: position,
PlayerStartSeconds: position,
CanSeekAnywhere: true,
SeekRestoration: seekRestorationPlayerV3,
}
return record.FrozenRecipe.PlannerResult(&plan), nil
}
type subtitleIndexLocationV3 struct {
source string
offset int
}
// classifySubtitleIndexV3 maps the combined subtitle index used by
// buildSubtitleURLs to its inventory segment and segment-local offset.
func classifySubtitleIndexV3(file *models.MediaFile, index int) (subtitleIndexLocationV3, bool) {
if file == nil || index < 0 {
return subtitleIndexLocationV3{}, false
}
externalCount := len(file.ExternalSubtitles)
if index < externalCount {
return subtitleIndexLocationV3{source: playback.SubtitleSourceExternalV3, offset: index}, true
}
embeddedOffset := index - externalCount
if embeddedOffset < len(file.SubtitleTracks) {
return subtitleIndexLocationV3{source: playback.SubtitleSourceEmbeddedV3, offset: embeddedOffset}, true
}
return subtitleIndexLocationV3{
source: playback.SubtitleSourceDownloadedV3,
offset: embeddedOffset - len(file.SubtitleTracks),
}, true
}
// freezeExecutableRecipeV3 extends the pure planner freeze with the identity
// of the selected sidecar subtitle. The combined subtitle index space
// (externals, then embedded, then downloaded — see buildSubtitleURLs) is not
// stable across inventory changes, so the index alone cannot anchor a durable
// selection. A downloaded selection whose identity cannot be established is
// an error: silently omitting it would disable drift detection for exactly
// the seeks this recipe exists to protect.
func (h *PlaybackHandler) freezeExecutableRecipeV3(_ context.Context, file *models.MediaFile, result playback.PlannerResultV3) (playback.ExecutableRecipeV3, error) {
recipe := playback.FreezeExecutableRecipeV3(result)
if file != nil {
recipe.SourceVideoCodec = file.CodecVideo
recipe.SourceDurationSeconds = float64(file.Duration)
}
if file == nil || result.SubtitleTrackIndex < 0 {
return recipe, nil
}
// A downloaded row ID was selected from the planner's inventory snapshot.
// Treat it as authoritative before consulting the mutable combined-index
// segments: an external or embedded subtitle added after planning must not
// make this downloaded selection look like a different source.
if recipe.DownloadedSubtitleID > 0 {
recipe.SubtitleSource = playback.SubtitleSourceDownloadedV3
return recipe, nil
}
location, ok := classifySubtitleIndexV3(file, result.SubtitleTrackIndex)
if !ok {
return recipe, nil
}
switch location.source {
case playback.SubtitleSourceExternalV3:
recipe.SubtitleSource = playback.SubtitleSourceExternalV3
recipe.ExternalSubtitlePath = file.ExternalSubtitles[location.offset].Path
case playback.SubtitleSourceEmbeddedV3:
recipe.SubtitleSource = playback.SubtitleSourceEmbeddedV3
recipe.EmbeddedStreamIndex = file.SubtitleTracks[location.offset].Index
case playback.SubtitleSourceDownloadedV3:
if recipe.DownloadedSubtitleID <= 0 {
return playback.ExecutableRecipeV3{}, errors.New("the selected downloaded subtitle has no stable identity")
}
}
return recipe, nil
}
// validateFrozenSubtitleIdentityV3 confirms the frozen combined subtitle
// index still resolves to the identical inventory entry it was frozen
// against. It mirrors the segment layout of buildSubtitleURLs so a change in
// any earlier segment's size — which shifts every later index — is detected
// as an identity mismatch rather than silently re-resolved.
func (h *PlaybackHandler) validateFrozenSubtitleIdentityV3(ctx context.Context, file *models.MediaFile, recipe playback.ExecutableRecipeV3) error {
if recipe.SubtitleSource == "" {
return nil
}
if file == nil || recipe.SubtitleTrackIndex < 0 {
return errors.New("the frozen subtitle selection is unavailable")
}
if recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
return errors.New("the downloaded subtitle inventory is unavailable")
}
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
if err != nil {
return wrapSubtitleStoreErrorV3(err)
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
return errors.New("the frozen downloaded subtitle identity changed")
}
return nil
}
location, ok := classifySubtitleIndexV3(file, recipe.SubtitleTrackIndex)
if !ok || location.source != recipe.SubtitleSource {
return errors.New("the frozen subtitle inventory segment changed")
}
switch recipe.SubtitleSource {
case playback.SubtitleSourceExternalV3:
if file.ExternalSubtitles[location.offset].Path != recipe.ExternalSubtitlePath {
return errors.New("the frozen external subtitle identity changed")
}
case playback.SubtitleSourceEmbeddedV3:
if file.SubtitleTracks[location.offset].Index != recipe.EmbeddedStreamIndex {
return errors.New("the frozen embedded subtitle identity changed")
}
default:
return errors.New("the frozen subtitle identity is unrecognized")
}
return nil
}
func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error {
if record == nil {
return errors.New("the current playback attempt is unavailable")
@@ -1332,41 +1564,66 @@ func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playbac
return nil
}
// seekReanchorIdentityChangesV3 returns only bounded, non-secret field names.
// It is safe for structured logs: values, URLs, headers, tokens, and subtitle
// artifact locations are deliberately excluded.
func seekReanchorIdentityChangesV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) []string {
if record == nil || candidate == nil {
return []string{"route"}
}
current := record.CurrentPlan
changed := make([]string, 0, 16)
add := func(name string, differs bool) {
if differs {
changed = append(changed, name)
}
}
add("plan_id", candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID)
add("requested_file_id", candidate.RequestedMediaFileID != record.RequestedMediaFileID)
add("effective_file_id", candidate.EffectiveMediaFileID != record.EffectiveMediaFileID)
add("delivery", candidate.Delivery != current.Delivery)
add("engine", candidate.Engine != current.Engine)
add("protocol", candidate.Stream.Protocol != current.Stream.Protocol)
add("container", candidate.Stream.Container != current.Stream.Container)
add("mime_type", candidate.Stream.MIMEType != current.Stream.MIMEType)
add("header_refresh", candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh)
add("video_codec", candidate.EffectiveRecipe.VideoCodec != current.EffectiveRecipe.VideoCodec)
add("audio_codec", candidate.EffectiveRecipe.AudioCodec != current.EffectiveRecipe.AudioCodec)
add("resolution", !optionalIntEqualV3(candidate.EffectiveRecipe.Width, current.EffectiveRecipe.Width) || !optionalIntEqualV3(candidate.EffectiveRecipe.Height, current.EffectiveRecipe.Height))
add("frame_rate", !optionalFloatEqualV3(candidate.EffectiveRecipe.FrameRate, current.EffectiveRecipe.FrameRate))
add("bitrate", !optionalIntEqualV3(candidate.EffectiveRecipe.BitrateKbps, current.EffectiveRecipe.BitrateKbps))
add("dynamic_range", candidate.EffectiveRecipe.DynamicRange != current.EffectiveRecipe.DynamicRange)
add("audio_channels", !optionalIntEqualV3(candidate.EffectiveRecipe.AudioChannels, current.EffectiveRecipe.AudioChannels) || candidate.EffectiveRecipe.AudioLayout != current.EffectiveRecipe.AudioLayout)
add("selected_audio", !sameTrackIdentityV3(candidate.SelectedTracks.Audio, current.SelectedTracks.Audio))
add("selected_subtitle", !sameTrackIdentityV3(candidate.SelectedTracks.Subtitle, current.SelectedTracks.Subtitle))
add("subtitle_mode", candidate.Subtitle.Mode != current.Subtitle.Mode || candidate.Subtitle.TrackID != current.Subtitle.TrackID)
add("subtitle_artifact_route", !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact))
add("subtitle_fidelity", candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy)
add("transformations", !sameTransformationsV3(candidate.Transformations, current.Transformations))
add("quirks", !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks))
add("runtime_corrections", !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections))
add("claims", candidate.Claims != current.Claims)
return changed
}
func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error {
if record == nil || candidate == nil {
return errors.New("seek reanchor produced no playback route")
}
current := record.CurrentPlan
if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID {
changedFields := seekReanchorIdentityChangesV3(record, candidate)
if len(changedFields) == 0 {
return nil
}
if containsStringExactV3(changedFields, "plan_id") {
return errors.New("seek reanchor changed the playback plan identity")
}
if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID {
if containsStringExactV3(changedFields, "requested_file_id") || containsStringExactV3(changedFields, "effective_file_id") {
return errors.New("seek reanchor changed the selected media version")
}
if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) {
if containsStringExactV3(changedFields, "selected_audio") || containsStringExactV3(changedFields, "selected_subtitle") {
return errors.New("seek reanchor changed selected tracks")
}
if candidate.Engine != current.Engine ||
candidate.Stream.MIMEType != current.Stream.MIMEType ||
candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh ||
!sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) ||
candidate.Claims != current.Claims ||
candidate.Subtitle.Mode != current.Subtitle.Mode ||
candidate.Subtitle.TrackID != current.Subtitle.TrackID ||
!sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) ||
candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy ||
!sameTransformationsV3(candidate.Transformations, current.Transformations) ||
!sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) ||
!sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) {
return errors.New("seek reanchor changed the playback route semantics")
}
generation := record.NormalizedRequest.OutputRouteGeneration
currentKey := playback.PlanAttemptKeyV3(current, generation, nil)
candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil)
if candidateKey != currentKey {
return errors.New("seek reanchor changed the playback route recipe")
}
return nil
return errors.New("seek reanchor changed the playback route semantics")
}
func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool {
@@ -1378,18 +1635,6 @@ func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool
return left.MIMEType == right.MIMEType && left.Format == right.Format
}
func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool {
return left.VideoCodec == right.VideoCodec &&
left.AudioCodec == right.AudioCodec &&
optionalIntEqualV3(left.Width, right.Width) &&
optionalIntEqualV3(left.Height, right.Height) &&
optionalFloatEqualV3(left.FrameRate, right.FrameRate) &&
optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) &&
left.DynamicRange == right.DynamicRange &&
optionalIntEqualV3(left.AudioChannels, right.AudioChannels) &&
left.AudioLayout == right.AudioLayout
}
func sameTransformationsV3(left, right []playback.TransformationV3) bool {
if len(left) != len(right) {
return false
@@ -1934,7 +2179,7 @@ func subtitleMIMEV3(format string) string {
case "pgs", "hdmv_pgs_subtitle":
return "application/octet-stream"
default:
return "text/vtt"
return subtitleMIMEVTTV3
}
}
@@ -1990,6 +2235,29 @@ func videoBitstreamFilterForPlanV3(plan *playback.PlanV3) string {
return ""
}
// lazyDVRPUStrippableV3 defers (and memoizes) the per-source RPU probe so the
// planner only shells out to ffmpeg when a Dolby Vision strip route is
// genuinely on the table; every other start never touches it.
//
// The probe belongs to planning, not to the transport: the plan's HDR10 promise
// and the durable session's RemuxDVMode are both derived from the strip
// decision and are re-read by the restart and audio-switch paths, so
// suppressing the filter downstream would leave those claims describing a
// stream the server is no longer producing.
func (h *PlaybackHandler) lazyDVRPUStrippableV3(ctx context.Context, file *models.MediaFile) func() bool {
if file == nil || strings.TrimSpace(file.FilePath) == "" {
return nil
}
var once sync.Once
strippable := true
return func() bool {
once.Do(func() {
strippable = playback.DVRPUStrippable(ctx, h.playbackConfig().FFmpegPath, file.FilePath)
})
return strippable
}
}
func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDuration int, durationSeconds float64) (float64, int) {
if plan == nil {
return 0, 0
@@ -1998,18 +2266,30 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
startSegment := computeStartSegment(seek, segmentDuration)
plan.Timeline.SourceStartSeconds = requested
if strings.EqualFold(videoCodec, "copy") {
plan.Timeline.PlayerStartSeconds = 0
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
if usesGrowingManifest {
// Encoded streams seek to the preceding segment boundary. Preserve the
// requested sub-segment offset so playback still begins at the exact
// requested source position. Copy seeks are already exact, making this 0.
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
plan.Timeline.StreamOriginSeconds = seek
plan.Timeline.TimelineOffsetSeconds = seek
windowStart := seek
plan.Timeline.SeekWindowStartSeconds = &windowStart
if durationSeconds > 0 {
windowEnd := durationSeconds
plan.Timeline.SeekWindowEndSeconds = &windowEnd
} else {
plan.Timeline.SeekWindowEndSeconds = nil
}
// This transport is served from FFmpeg's live, still-growing playlist
// (see BuildPlaybackManifest), so the seekable extent is whatever has
// been produced so far — a value this plan cannot know and could not
// keep current if it did. Publishing the media runtime here instead
// made the window look *complete*, which clients read as proof that
// any target inside it is locally seekable; they then native-seek past
// the produced head instead of asking for a reanchor. Leaving the end
// open marks the window incomplete, which with can_seek_anywhere=false
// routes every seek back through the server.
//
// The media runtime is published on source.duration_seconds, which is
// a fact about the file rather than a claim about this transport.
plan.Timeline.SeekWindowEndSeconds = nil
plan.Timeline.CanSeekAnywhere = false
plan.Timeline.SeekRestoration = "source_position"
} else {
@@ -2019,7 +2299,7 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
plan.Timeline.SeekWindowStartSeconds = nil
plan.Timeline.SeekWindowEndSeconds = nil
plan.Timeline.CanSeekAnywhere = durationSeconds > 0
plan.Timeline.SeekRestoration = "player_position"
plan.Timeline.SeekRestoration = seekRestorationPlayerV3
}
return seek, startSegment
}
+655 -4
View File
@@ -3,7 +3,9 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
@@ -16,6 +18,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
@@ -605,7 +608,291 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing
}
}
func TestHandleReplanPlaybackV3SeekReanchorIgnoresRefreshedProbeMetadata(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
selectedAudio := 1
startRequest.AudioTrackIndex = &selectedAudio
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var started playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.PlaybackPlan == nil || started.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
t.Fatalf("start plan = %#v", started.PlaybackPlan)
}
// Simulate a refreshed probe changing a live planner input after the route
// was durably accepted. A seek is not authority to consume that drift.
file.Container = "mkv"
file.AudioTracks = file.AudioTracks[:1]
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
reanchor := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "seek-reanchor-probe-drift-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "plan-attempt-seek-probe-0001", PlanAttemptKey: currentKey,
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 321,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
}
body, err := json.Marshal(reanchor)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", started.SessionID)
rr := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID ||
response.PlaybackPlan.Delivery != started.PlaybackPlan.Delivery ||
response.PlaybackPlan.Timeline.SourceStartSeconds != 321 {
t.Fatalf("reanchored plan = %#v, terminal = %#v", response.PlaybackPlan, response.Terminal)
}
}
func TestStartPlaybackV3FreezesDownloadedSubtitleFromPlanningSnapshot(t *testing.T) {
file := v3HandlerFixtureFile(t)
repo := newMockSubtitleRepoForHandler()
first := subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
reordered := subtitles.DownloadedSubtitle{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}
repo.listResults = [][]subtitles.DownloadedSubtitle{{first}, {reordered, first}}
repo.subtitles[first.ID] = &first
repo.subtitles[reordered.ID] = &reordered
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.SubtitleRepo = repo
request := v3HandlerStartRequest()
downloadedIndex := 0
request.SubtitleTrackIndex = &downloadedIndex
request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", downloadedIndex)
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.PlaybackPlan.Subtitle.Artifact == nil ||
!strings.Contains(response.PlaybackPlan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
t.Fatalf("playback plan = %#v, want downloaded subtitle 71", response.PlaybackPlan)
}
if repo.listCalls != 1 {
t.Fatalf("downloaded inventory listed %d times, want one planning snapshot", repo.listCalls)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
if err != nil {
t.Fatal(err)
}
if record.FrozenRecipe.DownloadedSubtitleID != 71 {
t.Fatalf("frozen downloaded subtitle = %d, want 71", record.FrozenRecipe.DownloadedSubtitleID)
}
}
func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
{Name: "video_to_h264", RecipeVersion: "1", Available: true},
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: true},
}))
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var active playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &active); err != nil {
t.Fatal(err)
}
if active.PlaybackPlan == nil || active.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
t.Fatalf("initial plan = %#v", active.PlaybackPlan)
}
attempted := []string{}
wantFallbacks := []playback.DeliveryV3{playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3}
for index, classification := range []string{"playback_error", "decoder_error"} {
currentKey := playback.PlanAttemptKeyV3(*active.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
attempted = append(attempted, currentKey)
response := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: fmt.Sprintf("fallback-replan-%04d", index+1),
FailedPlanID: active.PlaybackPlan.PlanID,
PlanAttemptID: fmt.Sprintf("fallback-plan-attempt-%04d", index+1),
PlanAttemptKey: currentKey, AttemptedPlanKeys: append([]string(nil), attempted...),
AttemptCount: index + 1, QualityPreference: startRequest.QualityPreference,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: active.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: classification},
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if response.PlaybackPlan == nil {
t.Fatalf("fallback %d = %#v", index, response)
}
if response.PlaybackPlan.Delivery != wantFallbacks[index] {
t.Fatalf("fallback %d delivery = %q, want %q; response=%#v", index, response.PlaybackPlan.Delivery, wantFallbacks[index], response)
}
active = response
}
if active.PlaybackPlan.Delivery != playback.DeliveryRemuxHLSV3 {
t.Fatalf("fallback route = %#v", active.PlaybackPlan)
}
frozen := *active.PlaybackPlan
currentKey := playback.PlanAttemptKeyV3(frozen, startRequest.OutputRouteGeneration, nil)
reanchored := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "fallback-seek-reanchor-0001", FailedPlanID: frozen.PlanID,
PlanAttemptID: "fallback-seek-plan-0001", PlanAttemptKey: currentKey,
AttemptCount: 3, QualityPreference: startRequest.QualityPreference, PositionSeconds: 819.185,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: frozen.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if reanchored.PlaybackPlan == nil || reanchored.PlaybackPlan.PlanID != frozen.PlanID ||
reanchored.PlaybackPlan.Delivery != frozen.Delivery || reanchored.PlaybackPlan.Engine != frozen.Engine ||
reanchored.PlaybackPlan.Timeline.SourceStartSeconds != 819.185 {
t.Fatalf("reanchored fallback = %#v, terminal = %#v", reanchored.PlaybackPlan, reanchored.Terminal)
}
}
// A pre-migration attempt row carries frozen_recipe = '{}', which decodes to
// an invalid zero recipe. A seek reanchor against it must fail with the
// dedicated retryable reason — telling the client to mint a fresh playback
// attempt — while leaving the durable attempt fully usable for subsequent
// failure replans.
func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var started playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.PlaybackPlan == nil {
t.Fatalf("start response = %s", startRR.Body.String())
}
// Simulate the row predating the frozen_recipe migration: the JSONB
// default '{}' unmarshals to the zero recipe.
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
record.FrozenRecipe = playback.ExecutableRecipeV3{}
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
seekResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "legacy-seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "legacy-seek-plan-0001", PlanAttemptKey: currentKey,
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if seekResponse.Terminal == nil || seekResponse.Terminal.Reason != "seek_reanchor_recipe_unavailable" || !seekResponse.Terminal.Retryable {
t.Fatalf("legacy seek reanchor = %#v, terminal = %#v", seekResponse.PlaybackPlan, seekResponse.Terminal)
}
// The failed seek must not have consumed the durable attempt: the current
// plan is unchanged and an ordinary failure replan still succeeds.
after, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if after.CurrentPlanID != started.PlaybackPlan.PlanID {
t.Fatalf("failed legacy seek moved the current plan: %q -> %q", started.PlaybackPlan.PlanID, after.CurrentPlanID)
}
recovery := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "legacy-recovery-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "legacy-recovery-plan-0001", PlanAttemptKey: currentKey,
AttemptedPlanKeys: []string{currentKey},
AttemptCount: 2, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: "playback_error"},
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if recovery.PlaybackPlan == nil {
t.Fatalf("failure replan after legacy seek = %#v, terminal = %#v", recovery.PlaybackPlan, recovery.Terminal)
}
}
func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) {
// This test has never passed. It fails at 854d07cf, the commit that
// introduced it, so it describes behavior that was specified and not
// implemented rather than behavior that regressed.
//
// What it asks for: when a seek fails and the client's replan capabilities
// have narrowed to 1080p, recovery must stay on the pinned 4K media version
// and must not video-transcode it. Today the planner takes the narrowed
// per-request capabilities at face value, finds the 4K source unplayable
// with allow_4k_transcode disabled, and answers adaptation_unavailable.
//
// Making it pass means deciding whether replan capabilities may narrow
// media-version selection at all, which is a protocol v3 planner change and
// does not belong to whichever change happens to notice the failure. Skipped
// rather than excluded in the Makefile so the reason travels with the test.
t.Skip("specifies unimplemented v3 planner behavior; see the comment above")
source := v3HandlerFixtureFile(t)
source.Resolution = "2160p"
source.Bitrate = 32_000
@@ -812,6 +1099,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
record.CurrentPlan,
)
record.CurrentPlanID = record.CurrentPlan.PlanID
record.FrozenRecipe.PlanID = record.CurrentPlan.PlanID
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil)
@@ -1045,6 +1333,283 @@ func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) {
}
}
func TestSeekReanchorIdentityChangesV3ReportsOnlyChangedFieldNames(t *testing.T) {
current := playback.PlanV3{
PlanID: "plan:current", Delivery: playback.DeliveryOriginalHTTPV3,
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4"},
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac"},
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
}
record := &playback.AttemptRecordV3{
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
CurrentPlanID: current.PlanID, CurrentPlan: current,
}
candidate := current
candidate.PlanID = "plan:candidate"
candidate.Delivery = playback.DeliveryRemuxHLSV3
candidate.Stream.Container = "hls"
candidate.EffectiveRecipe.AudioCodec = "ac3"
got := strings.Join(seekReanchorIdentityChangesV3(record, &candidate), ",")
if want := "plan_id,delivery,container,audio_codec"; got != want {
t.Fatalf("changed fields = %q, want %q", got, want)
}
}
func TestFrozenSeekReanchorResultV3PreservesRouteMatrix(t *testing.T) {
audioIndex := 0
basePlan := func(name string) playback.PlanV3 {
return playback.PlanV3{
ProtocolVersion: playback.ProtocolV3, PlanID: "plan:" + name,
Delivery: playback.DeliveryOriginalHTTPV3, Engine: playback.EngineMedia3DirectV3,
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshSessionV3},
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}},
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", DynamicRange: "sdr"},
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleOffV3},
Transformations: []playback.TransformationV3{}, AppliedQuirks: []playback.AppliedQuirkV3{}, RuntimeCorrections: []string{},
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
}
}
tests := []struct {
name string
mutate func(*playback.PlanV3, *playback.PlannerResultV3)
}{
{name: "direct"},
{name: "progressive remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxProgressiveV3
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
result.PlayMethod = playback.PlayRemux
}},
{name: "HLS remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxHLSV3
plan.Engine = playback.EngineMedia3HLSV3
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
result.PlayMethod = playback.PlayRemux
result.TargetVideoCodec = "copy"
result.TargetAudioCodec = "copy"
}},
{name: "audio converting remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxProgressiveV3
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
plan.Transformations = []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}
result.PlayMethod = playback.PlayRemux
result.TranscodeAudio = true
result.TargetAudioCodec = "aac"
result.TargetAudioChannels = 2
}},
{name: "downloaded subtitle", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
subtitleIndex := 7
plan.SelectedTracks.Subtitle = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "subtitle", subtitleIndex), Index: &subtitleIndex}
plan.Subtitle = playback.SubtitleDecisionV3{Mode: playback.SubtitleConvertV3, TrackID: plan.SelectedTracks.Subtitle.ID, Artifact: &playback.SubtitleArtifactV3{MIMEType: "text/vtt", Format: "vtt"}}
result.SubtitleTrackIndex = subtitleIndex
result.SubtitleTransportTrackIndex = subtitleIndex
result.SubtitleCodec = "srt"
result.DownloadedSubtitleID = 71
}},
{name: "Dolby Vision transformation", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
plan.EffectiveRecipe.DynamicRange = "hdr10"
plan.Transformations = []playback.TransformationV3{{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1"}}
}},
{name: "pooled node only transformation", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryTranscodeHLSV3
plan.Engine = playback.EngineMedia3HLSV3
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
plan.Transformations = []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}}
result.PlayMethod = playback.PlayTranscode
result.TargetVideoCodec = "h264"
result.TargetAudioCodec = "aac"
result.TargetResolution = "1080p"
}},
{name: "device quirks and runtime corrections", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
plan.AppliedQuirks = []playback.AppliedQuirkV3{{ID: "apple-hls-audio", RegistryRevision: "3", Action: "force-aac"}}
plan.RuntimeCorrections = []string{"pcm_fallback"}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := basePlan(strings.ReplaceAll(test.name, " ", "-"))
operational := playback.PlannerResultV3{Plan: &plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
if test.mutate != nil {
test.mutate(&plan, &operational)
}
operational.Plan = &plan
record := &playback.AttemptRecordV3{
PlaybackAttemptID: "attempt-matrix-0001", RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
CurrentPlanID: plan.PlanID, CurrentPlan: plan, FrozenRecipe: playback.FreezeExecutableRecipeV3(operational),
}
result, err := frozenSeekReanchorResultV3(record, 321.25, time.Unix(1_786_000_000, 0))
if err != nil || result.Plan == nil {
t.Fatalf("frozen reanchor: result=%#v err=%v", result, err)
}
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
t.Fatalf("route semantics changed: %v, plan=%#v", err, result.Plan)
}
if result.Plan.Timeline.SourceStartSeconds != 321.25 || result.PlayMethod != operational.PlayMethod ||
result.TranscodeAudio != operational.TranscodeAudio || result.TargetVideoCodec != operational.TargetVideoCodec ||
result.TargetAudioCodec != operational.TargetAudioCodec || result.TargetAudioChannels != operational.TargetAudioChannels ||
result.TargetResolution != operational.TargetResolution || result.SubtitleTrackIndex != operational.SubtitleTrackIndex ||
result.SubtitleTransportTrackIndex != operational.SubtitleTransportTrackIndex || result.SubtitleBurnIn != operational.SubtitleBurnIn ||
result.SubtitleCodec != operational.SubtitleCodec || result.DownloadedSubtitleID != operational.DownloadedSubtitleID {
t.Fatalf("operational recipe changed: got=%#v want=%#v", result, operational)
}
})
}
}
func TestFrozenDownloadedSubtitleV3AcceptsInventoryReordering(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "srt"}}
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass"}}
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
plan := &playback.PlanV3{PlanID: "plan:downloaded", SelectedTracks: playback.SelectedTracksV3{Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "subtitle", downloadedIndex), Index: &downloadedIndex}}}
result := playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex, DownloadedSubtitleID: 71}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
if err != nil {
t.Fatalf("freeze: %v", err)
}
if recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
t.Fatalf("frozen subtitle identity = %q/%d, want downloaded/71", recipe.SubtitleSource, recipe.DownloadedSubtitleID)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
t.Fatalf("stable inventory rejected: %v", err)
}
repo.list = []subtitles.DownloadedSubtitle{{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}, {ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}}
repo.listErr = errors.New("mutable inventory must not be consulted")
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
t.Fatalf("reordered downloaded subtitle inventory rejected stable identity: %v", err)
}
repo.getErr = errors.New("database unavailable")
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); !errors.Is(err, errSubtitleStoreUnavailableV3) {
t.Fatalf("validation error = %v, want wrapped subtitle-store failure", err)
}
}
func TestAttachSubtitleArtifactV3UsesFrozenDownloadedIdentityWithoutOrdinalLookup(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = nil
file.SubtitleTracks = nil
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{
ID: 71, MediaFileID: file.ID, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt",
}
// A second ordinal lookup would either fail or select a different row.
// Artifact attachment must use GetDownloadedSubtitle(71) exclusively.
repo.listErr = errors.New("mutable inventory must not be consulted")
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
selectedIndex := 0
plan := &playback.PlanV3{
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
Timeline: playback.TimelineV3{StreamOriginSeconds: 321},
}
recipe := playback.ExecutableRecipeV3{
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
SubtitleTrackIndex: selectedIndex, SubtitleCodec: "vtt",
}
if err := handler.attachSubtitleArtifactV3(context.Background(), "session-frozen-subtitle", file, plan, selectedIndex, &recipe); err != nil {
t.Fatalf("attach frozen downloaded subtitle: %v", err)
}
if plan.Subtitle.Artifact == nil || !strings.Contains(plan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
t.Fatalf("artifact = %#v, want frozen downloaded subtitle 71", plan.Subtitle.Artifact)
}
}
func TestSubtitleArtifactStoreFailuresAreRetryable(t *testing.T) {
storeErr := errors.New("database unavailable")
wantRetryable := subtitleArtifactErrorV3("subtitle lookup failed", wrapSubtitleStoreErrorV3(storeErr))
if !wantRetryable.retryable || !errors.Is(wantRetryable.cause, errSubtitleStoreUnavailableV3) {
t.Fatalf("store failure = %#v, want retryable subtitle error", wantRetryable)
}
wantPermanent := subtitleArtifactErrorV3("subtitle identity changed", errors.New("identity changed"))
if wantPermanent.retryable {
t.Fatalf("identity failure = %#v, want non-retryable subtitle error", wantPermanent)
}
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = nil
file.SubtitleTracks = nil
repo := newMockSubtitleRepoForHandler()
repo.getErr = storeErr
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
plan := &playback.PlanV3{
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
}
recipe := playback.ExecutableRecipeV3{
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
SubtitleTrackIndex: 0, SubtitleCodec: "vtt",
}
err := handler.attachSubtitleArtifactV3(context.Background(), "session-store-error", file, plan, 0, &recipe)
if !errors.Is(err, errSubtitleStoreUnavailableV3) {
t.Fatalf("attach error = %v, want wrapped subtitle-store failure", err)
}
}
func TestFreezeExecutableRecipeV3FailsLoudlyWhenDownloadedIdentityUnavailable(t *testing.T) {
file := v3HandlerFixtureFile(t)
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
result := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:downloaded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex}
if _, err := handler.freezeExecutableRecipeV3(context.Background(), file, result); err == nil {
t.Fatal("downloaded subtitle without a planning-snapshot identity was frozen")
}
result.DownloadedSubtitleID = 71
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/added-after-planning.srt", Format: "srt"}}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
if err != nil || recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
t.Fatalf("freeze planning snapshot identity: recipe=%#v err=%v", recipe, err)
}
}
func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/en.srt", Format: "srt"}, {Path: "/subs/de.srt", Format: "srt"}}
file.SubtitleTracks = []models.SubtitleTrack{{Index: 3, Codec: "ass"}}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
externalIndex := 1 // /subs/de.srt
externalResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:external"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: externalIndex, SubtitleTransportTrackIndex: externalIndex}
externalRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, externalResult)
if err != nil {
t.Fatalf("freeze external: %v", err)
}
if externalRecipe.SubtitleSource != playback.SubtitleSourceExternalV3 || externalRecipe.ExternalSubtitlePath != "/subs/de.srt" {
t.Fatalf("frozen external identity = %q/%q", externalRecipe.SubtitleSource, externalRecipe.ExternalSubtitlePath)
}
embeddedIndex := 2 // combined index of SubtitleTracks[0]
embeddedResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:embedded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: embeddedIndex, SubtitleTransportTrackIndex: 3}
embeddedRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, embeddedResult)
if err != nil {
t.Fatalf("freeze embedded: %v", err)
}
if embeddedRecipe.SubtitleSource != playback.SubtitleSourceEmbeddedV3 || embeddedRecipe.EmbeddedStreamIndex != 3 {
t.Fatalf("frozen embedded identity = %q/%d", embeddedRecipe.SubtitleSource, embeddedRecipe.EmbeddedStreamIndex)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err != nil {
t.Fatalf("stable external inventory rejected: %v", err)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err != nil {
t.Fatalf("stable embedded inventory rejected: %v", err)
}
// Deleting the first external subtitle shifts every later combined index.
// The frozen external selection now points past the external segment and
// the frozen embedded selection resolves one entry early; both must be
// rejected rather than silently re-resolved to a different artifact.
file.ExternalSubtitles = file.ExternalSubtitles[1:]
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err == nil {
t.Fatal("external subtitle deletion was accepted for the frozen external selection")
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err == nil {
t.Fatal("external subtitle deletion was accepted for the frozen embedded selection")
}
}
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
@@ -1158,6 +1723,54 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
}
}
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
t.Errorf("decode remote start: %v", err)
}
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer remote.Close()
file := v3HandlerFixtureFile(t)
file.CodecVideo = "hevc"
file.Duration = 7_201
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
plan := &playback.PlanV3{PlanID: "plan:frozen-source", Delivery: playback.DeliveryRemuxHLSV3}
initial := playback.PlannerResultV3{
Plan: plan, PlayMethod: playback.PlayRemux, TargetVideoCodec: "copy", TargetAudioCodec: "copy",
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, initial)
if err != nil {
t.Fatalf("freeze executable recipe: %v", err)
}
file.CodecVideo = "mpeg2video"
file.Duration = 99
result := recipe.PlannerResult(plan)
request := httptest.NewRequest(http.MethodPost, "/", nil)
transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-frozen-source", UserID: 7, ProfileID: "profile-1"}, file, result)
if transportErr != nil {
t.Fatalf("prepare remote transport: %v", transportErr)
}
defer transport.rollback()
if startRequest.SourceVideoCodec != "hevc" || startRequest.TotalDuration != 7_201 {
t.Fatalf("remote start consumed refreshed probe metadata: %#v", startRequest)
}
}
func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
@@ -1195,11 +1808,16 @@ func TestHandleStartPlaybackLegacyBranchPreservesTrailingBodyBehavior(t *testing
}
func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// A copy remux streams FFmpeg's growing playlist, so its seek window must
// stay open-ended even though the runtime is known. A bounded window reads
// as "complete", which clients treat as proof that any target inside it is
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
copyPlan.Timeline.SeekWindowEndSeconds == nil || *copyPlan.Timeline.SeekWindowEndSeconds != 600 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
}
@@ -1211,10 +1829,23 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
encodePlan.Timeline.SeekRestoration != "player_position" {
t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment)
}
longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000)
if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(longEncodePlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || longEncodePlan.Timeline.CanSeekAnywhere ||
longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 ||
longEncodePlan.Timeline.SeekWindowEndSeconds != nil ||
longEncodePlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("long encode timeline=%#v seek=%v segment=%d", longEncodePlan.Timeline, longEncodeSeek, longEncodeSegment)
}
unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
if unknownDurationPlan.Timeline.CanSeekAnywhere {
t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline)
unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(unknownDurationPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || unknownDurationPlan.Timeline.CanSeekAnywhere ||
unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 ||
unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil ||
unknownDurationPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment)
}
}
@@ -1339,3 +1970,23 @@ func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string
}
return string(body)
}
func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID string, request playback.ReplanRequestV3) playback.DecisionResponseV3 {
t.Helper()
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+sessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", sessionID)
rr := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response
}
+8 -171
View File
@@ -201,69 +201,10 @@ type pluginPresentationJSON struct {
LicenseSPDX string `json:"license_spdx"`
}
type pluginConfigSchemaJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
JSONSchema string `json:"json_schema"`
Required bool `json:"required"`
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
}
type pluginAdminFormJSON struct {
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
}
type pluginAdminFormFieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type pluginAdminFormOptionJSON struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type pluginAdminFormConditionJSON struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type pluginAdminFormValidationJSON struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type pluginAdminFormSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
}
type pluginConfigSchemaJSON = plugins.ConfigSchemaView
type pluginAdminFormJSON = plugins.AdminFormView
type pluginAdminFormFieldJSON = plugins.AdminFormFieldView
type pluginAdminFormSectionJSON = plugins.AdminFormSectionView
type pluginCapabilityJSON struct {
Type string `json:"type"`
@@ -855,6 +796,8 @@ func uploadErrorResponse(err error) (int, string) {
return http.StatusConflict, "Upload session is incomplete"
case errors.Is(err, uploads.ErrAlreadyCompleted):
return http.StatusConflict, "Upload session is already complete"
case errors.Is(err, uploads.ErrChunkBusy):
return http.StatusConflict, "This chunk is already being uploaded"
case errors.Is(err, uploads.ErrInvalidChunk), errors.Is(err, uploads.ErrInvalidRequest):
return http.StatusBadRequest, err.Error()
default:
@@ -1565,117 +1508,11 @@ func toUserPluginSettingsSummary(
}
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
for _, schema := range schemas {
if schema == nil {
continue
}
response = append(response, pluginConfigSchemaJSON{
Key: schema.GetKey(),
Title: schema.GetTitle(),
Description: schema.GetDescription(),
JSONSchema: schema.GetJsonSchema(),
Required: schema.GetRequired(),
AdminForm: adminFormToJSON(schema.GetAdminForm()),
})
}
return response
return plugins.ConfigSchemaViews(schemas)
}
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
if form == nil {
return nil
}
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
for _, field := range form.GetFields() {
if field == nil {
continue
}
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
for _, option := range field.GetOptions() {
if option == nil {
continue
}
options = append(options, pluginAdminFormOptionJSON{
Value: option.GetValue(),
Label: option.GetLabel(),
Description: option.GetDescription(),
})
}
var defaultValue any
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *pluginAdminFormValidationJSON
if v := field.GetValidation(); v != nil {
validation = &pluginAdminFormValidationJSON{
HasMin: v.GetHasMin(),
Min: v.GetMin(),
HasMax: v.GetHasMax(),
Max: v.GetMax(),
Pattern: v.GetPattern(),
MinLength: v.GetMinLength(),
MaxLength: v.GetMaxLength(),
}
}
fields = append(fields, pluginAdminFormFieldJSON{
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(),
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
Validation: validation,
ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, pluginAdminFormSectionJSON{
Key: section.GetKey(),
Title: section.GetTitle(),
Description: section.GetDescription(),
Collapsible: section.GetCollapsible(),
CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
})
}
return &pluginAdminFormJSON{
Fields: fields,
SubmitLabel: form.GetSubmitLabel(),
Sections: sections,
}
}
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
if len(conditions) == 0 {
return nil
}
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
for _, condition := range conditions {
if condition == nil {
continue
}
out = append(out, pluginAdminFormConditionJSON{
Field: condition.GetField(),
Equals: append([]string(nil), condition.GetEquals()...),
})
}
if len(out) == 0 {
return nil
}
return out
return plugins.AdminFormViewFromProto(form)
}
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
+2 -2
View File
@@ -284,7 +284,7 @@ func (h *ProfileHandler) HandleUploadAvatar(w http.ResponseWriter, r *http.Reque
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *updatedProfile))
}
func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
@@ -326,5 +326,5 @@ func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Reque
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *updatedProfile))
}
+92 -83
View File
@@ -15,6 +15,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
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/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -35,6 +36,10 @@ type ProfileHandler struct {
DeviceLibraryPurger interface {
PurgeProfileDevices(ctx context.Context, userID int, profileID string) error
}
// EventsHub, when set, receives a user_settings.changed event for every
// canonical setting row a profile mutation syncs (see
// profiles_settings_sync.go). Nil (as in tests) simply skips publishing.
EventsHub *evt.Hub
}
// NewProfileHandler creates a new ProfileHandler.
@@ -132,78 +137,24 @@ type verifyPINResponse struct {
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
// profiles belonging to their user.
//
// When that primary profile has a PIN, management also requires a valid
// X-Profile-Token from `/profiles/{id}/verify-pin`; otherwise a client could
// bypass the profile lock by sending only X-Profile-Id.
// The rule lives in household.go so the settings routes can apply the same one:
// a household parent who may edit a child's profile may also edit that child's
// device settings, and two definitions of "is this the household parent" would
// eventually disagree.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
ctx := r.Context()
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
if activeProfileID == "" {
activeProfileID = r.Header.Get("X-Profile-Id")
}
if activeProfileID == "" {
return false, nil
}
active, err := store.GetProfile(ctx, activeProfileID)
if err != nil {
return false, err
}
if active == nil {
return false, nil
}
if !active.IsPrimary {
return false, nil
}
if active.PINHash == "" {
return true, nil
}
if err := h.requireVerifiedProfileToken(r, active.ID); err != nil {
return false, err
}
return true, nil
return canManageHousehold(r, store, h.userLookupOrNil(), h.ProfileTokens)
}
func (h *ProfileHandler) requireVerifiedProfileToken(r *http.Request, profileID string) error {
if h.UserRepo == nil || h.ProfileTokens == nil {
return access.ErrProfileUnverified
// userLookupOrNil returns UserRepo as the narrow interface the household check
// wants, preserving nil-ness: a typed nil in a non-nil interface would defeat
// the fail-closed check there.
func (h *ProfileHandler) userLookupOrNil() userLookup {
if h.UserRepo == nil {
return nil
}
claims := apimw.GetClaims(r.Context())
if claims == nil || claims.SessionID == "" {
return access.ErrProfileUnverified
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return access.ErrProfileUnverified
}
user, err := h.UserRepo.GetByID(r.Context(), userID)
if err != nil {
return fmt.Errorf("loading user policy: %w", err)
}
if user == nil {
return access.ErrProfileUnverified
}
profileClaims, err := h.ProfileTokens.Validate(r.Header.Get("X-Profile-Token"))
if err != nil {
return err
}
if profileClaims.UserID != userID ||
profileClaims.SessionID != claims.SessionID ||
profileClaims.ProfileID != profileID ||
profileClaims.PolicyRevision != user.AccessPolicyRevision {
return access.ErrProfileUnverified
}
return nil
return h.UserRepo
}
func writeProfileManagementPermissionError(w http.ResponseWriter, err error) {
@@ -275,12 +226,9 @@ func (h *ProfileHandler) HandleListProfiles(w http.ResponseWriter, r *http.Reque
}
resp := profileListResponse{
Profiles: make([]profileResponse, 0, len(profiles)),
Profiles: h.toProfileResponses(r.Context(), store, profiles),
AvatarUploadEnabled: h.AvatarStore != nil,
}
for _, p := range profiles {
resp.Profiles = append(resp.Profiles, h.toProfileResponse(r.Context(), p))
}
writeJSON(w, http.StatusOK, resp)
}
@@ -315,6 +263,14 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
return
}
// Planned before anything is written: a preference value the canonical
// store would refuse must fail the request while it is still a no-op.
settingsSync, err := planCreateProfileSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
@@ -412,8 +368,10 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
MaxPlaybackQuality: maxPlaybackQuality,
}
if err := store.CreateProfile(r.Context(), profile); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create profile")
if err := h.createProfileWithSettingsSync(r.Context(), store, userID, profile, settingsSync); err != nil {
slog.ErrorContext(r.Context(), "profile create failed to sync canonical settings",
"component", "api", "user_id", userID, "profile_id", profileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store profile preferences")
return
}
@@ -456,7 +414,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
created = *p
}
writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), created))
writeJSON(w, http.StatusCreated, h.toProfileResponse(r.Context(), store, created))
}
// HandleUpdateProfile handles PUT /profiles/{id}.
@@ -566,6 +524,14 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
}
}
// Planned before the transaction so an invalid preference fails while the
// request is still a no-op.
settingsSync, err := planUpdateProfileSettingsSync(req)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input := userstore.UpdateProfileInput{
Name: req.Name,
Avatar: avatarRef,
@@ -587,8 +553,15 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
MaxPlaybackQuality: maxPlaybackQuality,
}
if err := store.UpdateProfile(r.Context(), profileID, input); err != nil {
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
// The profile columns and their canonical projections commit together. A
// failure cannot leave a 500 response whose legacy values look saved while
// canonical readers continue serving the previous preference.
if err := h.applyProfileUpdateSettingsSync(
r.Context(), store, userID, profileID, input, settingsSync,
); err != nil {
slog.ErrorContext(r.Context(), "profile update failed to sync canonical settings",
"component", "api", "user_id", userID, "profile_id", profileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store profile preferences")
return
}
if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) {
@@ -604,7 +577,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
return
}
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *profile))
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), store, *profile))
}
// HandleDeleteProfile handles DELETE /profiles/{id}.
@@ -747,7 +720,43 @@ func (h *ProfileHandler) HandleVerifyPIN(w http.ResponseWriter, r *http.Request)
// --- Helpers ---
func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Profile) profileResponse {
// toProfileResponse serializes one profile, resolving its preference block on
// its own. Callers serializing several profiles must use toProfileResponses
// instead so the whole list costs one store read.
func (h *ProfileHandler) toProfileResponse(
ctx context.Context, store userstore.UserStore, p userstore.Profile,
) profileResponse {
prefs := resolveProfilePreferences(ctx, store, []string{p.ID})
return h.profileResponseWith(ctx, p, prefs[p.ID])
}
// toProfileResponses serializes a whole household, resolving every profile's
// preference block in one store read rather than one per profile.
func (h *ProfileHandler) toProfileResponses(
ctx context.Context, store userstore.UserStore, profiles []userstore.Profile,
) []profileResponse {
ids := make([]string, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.ID)
}
prefs := resolveProfilePreferences(ctx, store, ids)
out := make([]profileResponse, 0, len(profiles))
for _, p := range profiles {
out = append(out, h.profileResponseWith(ctx, p, prefs[p.ID]))
}
return out
}
// profileResponseWith builds the DTO from a profile row and its already
// resolved preferences.
//
// The preference fields come from prefs rather than from p: those five are
// canonical now, and the legacy columns behind them are written but no longer
// read (see profiles_settings_sync.go). Everything else is still column-backed.
func (h *ProfileHandler) profileResponseWith(
ctx context.Context, p userstore.Profile, prefs profilePreferences,
) profileResponse {
avatarSource, avatarURL := resolveProfileAvatar(ctx, h.AvatarStore, h.AvatarTTL, p.Avatar)
return profileResponse{
ID: p.ID,
@@ -760,15 +769,15 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof
IsPrimary: p.IsPrimary,
MaxContentRating: p.MaxContentRating,
QualityPreference: p.QualityPreference,
Language: p.Language,
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
SubtitleLanguage: p.SubtitleLanguage,
SubtitleMode: p.SubtitleMode,
Language: prefs.AudioLanguage,
PreferredMetadataLanguage: prefs.MetadataLanguage,
SubtitleLanguage: prefs.SubtitleLanguage,
SubtitleMode: prefs.SubtitleMode,
AutoSkipIntro: p.AutoSkipIntro,
AutoSkipCredits: p.AutoSkipCredits,
AutoSkipRecap: p.AutoSkipRecap,
AutoPlayNextPreview: p.AutoPlayNextPreview,
ShowForcedSubtitles: p.ShowForcedSubtitles,
ShowForcedSubtitles: prefs.ShowForcedSubtitles,
LibraryRestrictionsEnabled: p.LibraryRestrictionsEnabled,
AllowedLibraryIDs: append([]int(nil), p.AllowedLibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(p.MaxPlaybackQuality),
@@ -0,0 +1,435 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
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/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// The legacy profile endpoints are still the write path shipped clients use
// for the preference columns, but every server-side reader of those
// preferences now resolves them canonically from user_setting_values:
// access.Resolver and policy.ViewerResolver for catalog.metadata_language,
// playback start and catalog detail for the playback.* preferences. The
// settings backfill runs once, so a column write that never reaches the
// canonical store simply never takes effect — the stale backfilled row, or
// the contract default, wins forever.
//
// Until the clients move to /settings/values, every profile create or update
// therefore mirrors its preference fields into the profile-scope canonical
// rows the readers consult. The mapping is the live-write counterpart of
// settingsmigrate.planProfiles with one deliberate difference: the migration
// skips a column still holding its schema default because it cannot tell
// "never decided" from "chose the default", while a live request names the
// field explicitly, so its value — default or not — is a real choice and is
// stored.
//
// quality_preference is deliberately not mirrored: the server never resolves
// the legacy column (playback requests carry the quality preference
// per-request), and the two-axis quality picker already writes
// playback.preferred_quality and playback.max_bitrate_kbps through
// /settings/values directly.
// profileSettingSync is one canonical write implied by a legacy profile
// mutation. A nil value clears the profile-scope row so resolution falls
// back to the contract default, which is how the legacy empty string spells
// "no preference".
type profileSettingSync struct {
key string
value json.RawMessage
}
// planCreateProfileSettingsSync plans the canonical writes for POST
// /profiles. Create requests carry plain strings, so an absent field arrives
// as "" and plans a no-op delete against the freshly created profile.
func planCreateProfileSettingsSync(req createProfileRequest) ([]profileSettingSync, error) {
return planProfileSettingsSync(
&req.Language, &req.SubtitleLanguage, &req.PreferredMetadataLanguage,
&req.SubtitleMode, req.ShowForcedSubtitles,
profileSkipFields{
autoSkipIntro: &req.AutoSkipIntro,
autoSkipCredits: &req.AutoSkipCredits,
autoSkipRecap: &req.AutoSkipRecap,
autoPlayNextPreview: &req.AutoPlayNextPreview,
})
}
// planUpdateProfileSettingsSync plans the canonical writes for PUT
// /profiles/{id}. A nil field was not part of the request and must not touch
// the canonical row; the shipped clients send single-field deltas.
func planUpdateProfileSettingsSync(req updateProfileRequest) ([]profileSettingSync, error) {
return planProfileSettingsSync(
req.Language, req.SubtitleLanguage, req.PreferredMetadataLanguage,
req.SubtitleMode, req.ShowForcedSubtitles,
profileSkipFields{
autoSkipIntro: req.AutoSkipIntro,
autoSkipCredits: req.AutoSkipCredits,
autoSkipRecap: req.AutoSkipRecap,
autoPlayNextPreview: req.AutoPlayNextPreview,
})
}
// profileSkipFields groups the four boolean playback toggles the profile DTO
// carries. They travel together because they behave identically: a nil field
// was not in the request, and a present one mirrors verbatim.
type profileSkipFields struct {
autoSkipIntro *bool
autoSkipCredits *bool
autoSkipRecap *bool
autoPlayNextPreview *bool
}
func planProfileSettingsSync(
audioLang, subtitleLang, metadataLang, subtitleMode *string,
showForced *bool,
skips profileSkipFields,
) ([]profileSettingSync, error) {
var out []profileSettingSync
var err error
for _, field := range []struct {
key string
raw *string
}{
{settingskeys.PlaybackAudioLanguage, audioLang},
{settingskeys.PlaybackSubtitleLanguage, subtitleLang},
{settingskeys.CatalogMetadataLanguage, metadataLang},
{settingskeys.PlaybackSubtitleMode, subtitleMode},
} {
if out, err = appendStringSync(out, field.key, field.raw); err != nil {
return nil, err
}
}
// The booleans have no "unset" spelling on the wire — the legacy columns
// are NOT NULL — so a present field always writes an explicit value.
for _, field := range []struct {
key string
raw *bool
}{
{settingskeys.PlaybackShowForcedSubtitles, showForced},
{settingskeys.PlaybackAutoSkipIntro, skips.autoSkipIntro},
{settingskeys.PlaybackAutoSkipCredits, skips.autoSkipCredits},
{settingskeys.PlaybackAutoSkipRecap, skips.autoSkipRecap},
{settingskeys.PlaybackAutoPlayNextPreview, skips.autoPlayNextPreview},
} {
if field.raw == nil {
continue
}
out = append(out, profileSettingSync{
key: field.key,
value: json.RawMessage(strconv.FormatBool(*field.raw)),
})
}
return out, nil
}
// appendStringSync plans one string-valued column. The empty string is the
// legacy spelling of "unset" for both the language columns and subtitle_mode,
// so it clears the canonical row; anything else must normalize under the
// contract — the same check /settings/values applies — so nothing reaches
// storage that the canonical endpoint would refuse, and an invalid value is
// reported instead of silently never taking effect.
func appendStringSync(out []profileSettingSync, key string, raw *string) ([]profileSettingSync, error) {
if raw == nil {
return out, nil
}
trimmed := strings.TrimSpace(*raw)
if trimmed == "" {
return append(out, profileSettingSync{key: key}), nil
}
encoded, err := json.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
normalized, err := normalizeCanonicalSettingValue(key, encoded)
if err != nil {
return nil, err
}
return append(out, profileSettingSync{key: key, value: normalized}), nil
}
// normalizeCanonicalSettingValue runs a planned value through the same
// contract validation the canonical mutation endpoint uses.
func normalizeCanonicalSettingValue(key string, raw json.RawMessage) (json.RawMessage, error) {
contract, err := settingscontract.Load()
if err != nil {
return nil, fmt.Errorf("loading the settings contract: %w", err)
}
def, ok := contract.Lookup(key)
if !ok {
return nil, fmt.Errorf("%s has no contract definition", key)
}
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
return normalized, nil
}
// createProfileWithSettingsSync creates the profile, snapshots surviving
// account-wide legacy settings, and writes every canonical row in one store
// transaction. PostgreSQL's transaction wrapper also holds a per-user
// advisory lock shared with legacy account-setting fan-out, closing the
// cross-replica create/write race.
func (h *ProfileHandler) createProfileWithSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profile userstore.Profile,
writes []profileSettingSync,
) error {
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("user store does not support atomic preference settings synchronization")
}
var changedKeys []string
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
if err := tx.CreateProfile(ctx, profile); err != nil {
return err
}
inherited, err := planInheritedLegacyUserSettings(ctx, tx)
if err != nil {
return err
}
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile, ProfileID: profile.ID,
}, append(writes, inherited...))
return err
})
if err != nil {
return err
}
for _, key := range changedKeys {
publishUserSettingsEvent(ctx, h.EventsHub, userID, profile.ID, key, string(settingscontract.ScopeProfile))
}
return nil
}
func (h *ProfileHandler) applyProfileUpdateSettingsSync(
ctx context.Context,
store userstore.UserStore,
userID int,
profileID string,
input userstore.UpdateProfileInput,
writes []profileSettingSync,
) error {
return applyLegacyPreferenceSettingsSync(ctx, store, h.EventsHub, userID, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile, ProfileID: profileID,
}, writes, func(tx userstore.PreferenceSettingsWriter) error {
return tx.UpdateProfile(ctx, profileID, input)
})
}
// applyLegacyPreferenceSettingsSync is the live-write counterpart of the
// migration planner for legacy preference endpoints. The legacy mutation and
// every canonical row commit in one store transaction; events are deliberately
// published afterwards so subscribers can never observe uncommitted state.
func applyLegacyPreferenceSettingsSync(
ctx context.Context,
store userstore.UserStore,
events *evt.Hub,
userID int,
base userstore.SettingIdentity,
writes []profileSettingSync,
legacyMutation func(userstore.PreferenceSettingsWriter) error,
) error {
var changedKeys []string
transactioner, ok := store.(userstore.PreferenceSettingsTransactioner)
if !ok {
return fmt.Errorf("user store does not support atomic preference settings synchronization")
}
err := transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
if err := legacyMutation(tx); err != nil {
return err
}
var err error
changedKeys, err = writeCanonicalSettingsSync(ctx, tx, base, writes)
return err
})
if err != nil {
return err
}
for _, key := range changedKeys {
publishUserSettingsEvent(ctx, events, userID, base.ProfileID, key, string(base.Scope))
}
return nil
}
func writeCanonicalSettingsSync(
ctx context.Context,
store userstore.PreferenceSettingsWriter,
base userstore.SettingIdentity,
writes []profileSettingSync,
) ([]string, error) {
changedKeys := make([]string, 0, len(writes))
for _, write := range writes {
identity := base
identity.Key = write.key
if write.value == nil {
removed, err := store.DeleteSettingValue(ctx, identity)
if err != nil {
return nil, fmt.Errorf("clearing %s: %w", write.key, err)
}
if !removed {
continue // nothing was stored, so nothing changed
}
} else if _, err := store.UpsertSettingValue(ctx, identity, write.value); err != nil {
return nil, fmt.Errorf("storing %s: %w", write.key, err)
}
changedKeys = append(changedKeys, write.key)
}
return changedKeys, nil
}
// --- Read side ---
//
// The profile DTO's preference fields are served from the same canonical rows
// the sync above writes, not from the legacy columns. Without this, a
// preference saved through PUT /settings/values lands in user_setting_values
// and is invisible in every profile DTO reader on every platform: the columns
// only move when a client goes through POST/PUT /profiles, and the cutover
// direction is that they stop being read rather than start being dual-written.
//
// The fallback is the contract default, never the column. A column holding a
// pre-cutover value that the one-time backfill already converted would
// otherwise resurface the moment its canonical row is unset — the "clear this
// preference" path would read as "restore the value from before the cutover".
// profilePreferences is the resolved form of the DTO's preference block. Each
// field is the effective value for one profile, already defaulted, so the
// serializer copies rather than decides.
type profilePreferences struct {
AudioLanguage string
MetadataLanguage string
SubtitleLanguage string
SubtitleMode string
ShowForcedSubtitles bool
}
// profilePreferenceKeys are the canonical keys behind the DTO's preference
// fields, in DTO field order.
//
// quality_preference has no entry: the legacy column is a single compound
// value while the contract splits it across playback.preferred_quality and
// playback.max_bitrate_kbps, so there is no lossless read and the field stays
// column-backed. The auto_skip_* and auto_play_next_preview fields do sync on
// write, but this list drives the DTO's read block, whose shape the clients
// pin; they keep reading their columns, which the sync now keeps current.
var profilePreferenceKeys = []string{
settingskeys.PlaybackAudioLanguage,
settingskeys.CatalogMetadataLanguage,
settingskeys.PlaybackSubtitleLanguage,
settingskeys.PlaybackSubtitleMode,
settingskeys.PlaybackShowForcedSubtitles,
}
// resolveProfilePreferences resolves the preference block for every listed
// profile in one store read.
//
// One read for the whole household rather than one per profile: GET /profiles
// serves several profiles and this is on its hot path. A resolution failure
// degrades to contract defaults rather than failing the request — these are
// presentation preferences, not an access boundary — but it is logged, because
// a store outage that silently hands every profile the defaults is otherwise
// indistinguishable from a household that never set anything.
func resolveProfilePreferences(
ctx context.Context,
store userstore.UserStore,
profileIDs []string,
) map[string]profilePreferences {
defaults := contractProfilePreferences()
out := make(map[string]profilePreferences, len(profileIDs))
for _, id := range profileIDs {
out[id] = defaults
}
if store == nil || len(profileIDs) == 0 {
return out
}
contract, err := settingscontract.Load()
if err != nil {
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: loading settings contract failed",
"component", "api", "error", err)
return out
}
resolved, err := settingsresolve.New(contract).ResolveProfiles(
ctx, store, profileIDs, profilePreferenceKeys, nil)
if err != nil {
slog.WarnContext(ctx, "profile preferences degraded to contract defaults: reading setting values failed",
"component", "api", "profiles", len(profileIDs), "error", err)
return out
}
for profileID, effective := range resolved {
prefs := defaults
for _, eff := range effective {
applyProfilePreference(&prefs, eff.Key, eff.Value)
}
out[profileID] = prefs
}
return out
}
// contractProfilePreferences is the block every profile starts from: the
// contract's own defaults, decoded once per request.
//
// It is derived from the manifest rather than hard-coded so a default that
// changes there changes here too. A contract that fails to load leaves the Go
// zero values, which is the same "no preference" the empty string and false
// have always spelled in this DTO.
func contractProfilePreferences() profilePreferences {
var prefs profilePreferences
contract, err := settingscontract.Load()
if err != nil {
return prefs
}
for _, key := range profilePreferenceKeys {
def, ok := contract.Lookup(key)
if !ok {
continue
}
applyProfilePreference(&prefs, key, def.DefaultValue)
}
return prefs
}
// applyProfilePreference decodes one canonical value into its DTO field.
//
// A value that fails to decode leaves the field as it was, so a single
// malformed row degrades one field to its default instead of the whole block.
// The language keys default to JSON null, which unmarshals into "" — the same
// spelling of "no preference" the legacy columns used.
func applyProfilePreference(prefs *profilePreferences, key string, value json.RawMessage) {
switch key {
case settingskeys.PlaybackAudioLanguage:
decodeSettingString(value, &prefs.AudioLanguage)
case settingskeys.CatalogMetadataLanguage:
decodeSettingString(value, &prefs.MetadataLanguage)
case settingskeys.PlaybackSubtitleLanguage:
decodeSettingString(value, &prefs.SubtitleLanguage)
case settingskeys.PlaybackSubtitleMode:
decodeSettingString(value, &prefs.SubtitleMode)
case settingskeys.PlaybackShowForcedSubtitles:
var forced bool
if json.Unmarshal(value, &forced) == nil {
prefs.ShowForcedSubtitles = forced
}
}
}
func decodeSettingString(value json.RawMessage, dst *string) {
var decoded string
if json.Unmarshal(value, &decoded) == nil {
*dst = strings.TrimSpace(decoded)
}
}
@@ -0,0 +1,610 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/cache"
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"
)
// These tests pin the seam the settings cutover opened: every server-side
// reader of the profile preferences resolves them from user_setting_values,
// while the shipped clients still write them through POST/PUT /profiles. A
// profile write that does not land in the canonical store never takes effect
// — the stale backfilled row (or the contract default) wins forever.
// updateProfileVia sends PUT /profiles/{id} as profile-1's own session.
func updateProfileVia(t *testing.T, handler *ProfileHandler, profileID, body string) *httptest.ResponseRecorder {
t.Helper()
req := newAuthorizedProfileRequestWithRole(
http.MethodPut, "/profiles/"+profileID, body, "user", profileID)
req = withProfileRouteParam(req, "id", profileID)
rr := httptest.NewRecorder()
handler.HandleUpdateProfile(rr, req)
return rr
}
func storedProfileSetting(t *testing.T, store userstore.UserStore, key, profileID string) *userstore.SettingValue {
t.Helper()
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfile,
ProfileID: profileID,
})
if err != nil {
t.Fatalf("reading canonical %s: %v", key, err)
}
return value
}
// TestUpdateProfileSyncsCanonicalMetadataLanguage replays the cutover bug: a
// backfilled canonical row said "fr", the user changes the metadata language
// to "de" through the legacy profile endpoint, and access-scope resolution
// must see "de" — not the stale "fr" the one-time backfill left behind.
func TestUpdateProfileSyncsCanonicalMetadataLanguage(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
// The one-time backfill stored the pre-cutover column value.
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"fr"`)); err != nil {
t.Fatalf("seeding backfilled row: %v", err)
}
rr := updateProfileVia(t, handler, "profile-1", `{"preferred_metadata_language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
// The SQLite per-user schema never grew a preferred_metadata_language
// column, so the canonical row is the only storage this write has — which
// is exactly why the sync must exist.
if got := access.PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "de" {
t.Errorf("canonical metadata language = %q after profile update, want %q", got, "de")
}
}
// TestUpdateProfileSyncsCanonicalAudioLanguage is the playback-start half: a
// profile that never had a backfilled row chooses a spoken language, and the
// canonical store — which handleStartPlaybackLegacy resolves — must carry it.
func TestUpdateProfileSyncsCanonicalAudioLanguage(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1", `{"language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
value := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, "profile-1")
if value == nil {
t.Fatal("no canonical playback.audio_language row after the profile update")
}
if string(value.Value) != `"de"` {
t.Errorf("canonical audio language = %s, want \"de\"", value.Value)
}
}
// TestUpdateProfileClearingLanguageClearsCanonicalRow: the legacy empty
// string means "no preference", spelled canonically as no row at all.
func TestUpdateProfileClearingLanguageClearsCanonicalRow(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1",
`{"preferred_metadata_language":"fr"}`); rr.Code != http.StatusOK {
t.Fatalf("seeding PUT = %d: %s", rr.Code, rr.Body.String())
}
if rr := updateProfileVia(t, handler, "profile-1",
`{"preferred_metadata_language":""}`); rr.Code != http.StatusOK {
t.Fatalf("clearing PUT = %d: %s", rr.Code, rr.Body.String())
}
if value := storedProfileSetting(t, store, settingskeys.CatalogMetadataLanguage, "profile-1"); value != nil {
t.Errorf("canonical row = %s after clearing, want none", value.Value)
}
if got := access.PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
t.Errorf("resolved metadata language = %q after clearing, want \"\"", got)
}
}
// TestUpdateProfileSyncsSubtitlePreferences covers the triple the player's
// subtitle picker still saves through PUT /profiles, resolved canonically by
// catalog detail since the earlier cutover.
func TestUpdateProfileSyncsSubtitlePreferences(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"subtitle_language":"ja","subtitle_mode":"always","show_forced_subtitles":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackSubtitleLanguage: `"ja"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
}
// TestUpdateProfileSyncsSkipPreferences. The player resolves these four keys
// canonically, so a legacy PUT that only moved the columns would return 200
// and change nothing about playback.
func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"auto_skip_intro":true,"auto_skip_credits":true,"auto_skip_recap":true,`+
`"auto_play_next_preview":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
for key, want := range map[string]string{
settingskeys.PlaybackAutoSkipIntro: `true`,
settingskeys.PlaybackAutoSkipCredits: `true`,
settingskeys.PlaybackAutoSkipRecap: `true`,
settingskeys.PlaybackAutoPlayNextPreview: `false`,
} {
value := storedProfileSetting(t, store, key, "profile-1")
if value == nil {
t.Errorf("no canonical %s row after the profile update", key)
continue
}
if string(value.Value) != want {
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
}
}
// A field the request omitted must not be written: the shipped clients
// send single-field deltas, and an absent field is not a choice. Its own
// store, since the test store's DSN is derived from the test name.
t.Run("omitted fields are not written", func(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
if rr := updateProfileVia(t, handler, "profile-1", `{"auto_skip_intro":true}`); rr.Code != http.StatusOK {
t.Fatalf("single-field PUT = %d: %s", rr.Code, rr.Body.String())
}
if value := storedProfileSetting(t, store, settingskeys.PlaybackAutoSkipCredits, "profile-1"); value != nil {
t.Errorf("an omitted field wrote %s", value.Value)
}
})
}
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
// endpoint would refuse must fail the request as a no-op instead of leaving
// the column and the canonical store disagreeing.
func TestUpdateProfileRejectsInvalidLanguageBeforeWriting(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1", `{"language":"!!!"}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("PUT of an invalid tag = %d, want 400: %s", rr.Code, rr.Body.String())
}
profile, err := store.GetProfile(context.Background(), "profile-1")
if err != nil || profile == nil {
t.Fatalf("reading profile: %v", err)
}
if profile.Language != "" {
t.Errorf("column = %q after a rejected write, want untouched", profile.Language)
}
if value := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, "profile-1"); value != nil {
t.Errorf("canonical row = %s after a rejected write, want none", value.Value)
}
}
// TestCreateProfileSyncsCanonicalLanguages: a profile born with preferences
// must be resolvable canonically from its first request.
func TestCreateProfileSyncsCanonicalLanguages(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de","preferred_metadata_language":"fr"}`,
"user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleCreateProfile(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rr.Code, rr.Body.String())
}
var created profileResponse
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
audio := storedProfileSetting(t, store, settingskeys.PlaybackAudioLanguage, created.ID)
if audio == nil || string(audio.Value) != `"de"` {
t.Errorf("canonical audio language after create = %v, want \"de\"", audio)
}
if got := access.PreferredMetadataLanguage(context.Background(), store, created.ID); got != "fr" {
t.Errorf("canonical metadata language after create = %q, want %q", got, "fr")
}
}
func TestCreateProfileInheritsSurvivingLegacyAccountSettings(t *testing.T) {
store := newProfileTestStore(t)
if err := store.SetSetting(context.Background(), searchMediaScopeSettingKey, "audiobook"); err != nil {
t.Fatalf("seeding legacy account setting: %v", err)
}
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Guest"}`, "user", "profile-1")
rec := httptest.NewRecorder()
handler.HandleCreateProfile(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body.String())
}
var created profileResponse
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding create response: %v", err)
}
value := storedProfileSetting(t, store, searchMediaScopeSettingKey, created.ID)
if value == nil || string(value.Value) != `"audiobook"` {
t.Fatalf("inherited canonical value = %+v", value)
}
}
// failingSettingsWriteStore fails every canonical setting write, simulating a
// store whose user_setting_values table is unavailable while profile CRUD
// still works.
type failingSettingsWriteStore struct {
userstore.UserStore
}
type failingPreferenceSettingsWriter struct {
userstore.PreferenceSettingsWriter
}
func (s failingSettingsWriteStore) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("settings storage unavailable")
}
func (s failingSettingsWriteStore) WithPreferenceSettingsTransaction(
ctx context.Context,
fn func(userstore.PreferenceSettingsWriter) error,
) error {
transactioner, ok := s.UserStore.(userstore.PreferenceSettingsTransactioner)
if !ok {
return errors.New("wrapped store does not support preference settings transactions")
}
return transactioner.WithPreferenceSettingsTransaction(ctx, func(tx userstore.PreferenceSettingsWriter) error {
return fn(failingPreferenceSettingsWriter{PreferenceSettingsWriter: tx})
})
}
func (w failingPreferenceSettingsWriter) UpsertSettingValue(
context.Context, userstore.SettingIdentity, json.RawMessage,
) (*userstore.SettingValue, error) {
return nil, errors.New("settings storage unavailable")
}
// TestCreateProfileRollsBackWhenSettingsSyncFails pins the atomic profile and
// canonical-settings transaction. A failed canonical write must leave no
// half-configured profile and the client's retry must not hit a name conflict.
func TestCreateProfileRollsBackWhenSettingsSyncFails(t *testing.T) {
base := newProfileTestStore(t)
store := failingSettingsWriteStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
req := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de"}`, "user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleCreateProfile(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("POST = %d, want 500: %s", rr.Code, rr.Body.String())
}
profiles, err := base.ListProfiles(context.Background())
if err != nil {
t.Fatalf("listing profiles: %v", err)
}
for _, p := range profiles {
if p.Name == "Kids" {
t.Fatalf("profile %q survived a failed settings sync", p.Name)
}
}
// The rollback lets the retry succeed once the store recovers.
retry := newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles",
`{"name":"Kids","language":"de"}`, "user", "profile-1")
retryRec := httptest.NewRecorder()
NewProfileHandler(testUserStoreProvider{store: base}).HandleCreateProfile(retryRec, retry)
if retryRec.Code != http.StatusCreated {
t.Fatalf("retry POST = %d, want 201: %s", retryRec.Code, retryRec.Body.String())
}
}
func TestUpdateProfileRollsBackWhenSettingsSyncFails(t *testing.T) {
base := newProfileTestStore(t)
store := failingSettingsWriteStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
before, err := base.GetProfile(context.Background(), "profile-1")
if err != nil || before == nil {
t.Fatalf("reading profile before update: profile=%+v err=%v", before, err)
}
rr := updateProfileVia(t, handler, "profile-1", `{"language":"de"}`)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("PUT = %d, want 500: %s", rr.Code, rr.Body.String())
}
after, err := base.GetProfile(context.Background(), "profile-1")
if err != nil || after == nil {
t.Fatalf("reading profile after rollback: profile=%+v err=%v", after, err)
}
if after.Language != before.Language {
t.Fatalf("legacy language after rollback = %q, want %q", after.Language, before.Language)
}
if value := storedProfileSetting(t, base, settingskeys.PlaybackAudioLanguage, "profile-1"); value != nil {
t.Fatalf("canonical language survived rollback: %+v", value)
}
}
// TestUpdateProfilePublishesUserSettingsEvents: the synced rows change what
// other clients resolve, so they get the same refresh signal a
// /settings/values write publishes.
func TestUpdateProfilePublishesUserSettingsEvents(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := handler.EventsHub.Subscribe()
defer unsubscribe()
rr := updateProfileVia(t, handler, "profile-1", `{"preferred_metadata_language":"de"}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
env := receiveUserSettingsEvent(t, events)
assertUserSettingsEnvelope(t, env, settingskeys.CatalogMetadataLanguage, "profile")
// A field the request did not carry publishes nothing.
select {
case extra := <-events:
t.Errorf("unexpected extra event for %s", extra.Data)
default:
}
}
// --- Read side ---
//
// The mirror of the tests above: the DTO's preference fields are served from
// the canonical rows, so a write that never touched a legacy column is still
// visible to every profile-DTO reader on every platform.
// listProfilesVia sends GET /profiles as profile-1's own session.
func listProfilesVia(t *testing.T, handler *ProfileHandler) profileListResponse {
t.Helper()
req := newAuthorizedProfileRequestWithRole(http.MethodGet, "/profiles", "", "user", "profile-1")
rr := httptest.NewRecorder()
handler.HandleListProfiles(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /profiles = %d: %s", rr.Code, rr.Body.String())
}
var resp profileListResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding profile list: %v", err)
}
return resp
}
func profileFromList(t *testing.T, resp profileListResponse, profileID string) profileResponse {
t.Helper()
for _, p := range resp.Profiles {
if p.ID == profileID {
return p
}
}
t.Fatalf("profile %s missing from the list response", profileID)
return profileResponse{}
}
// TestListProfilesServesCanonicalWrite is the cross-client coherence gap this
// read path exists to close: a preference saved through PUT
// /settings/values?scope=profile writes only user_setting_values, and the
// profile DTO — which the Apple clients read — must reflect it on the next GET
// without the legacy column having moved at all.
func TestListProfilesServesCanonicalWrite(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
before, err := store.GetProfile(ctx, "profile-1")
if err != nil || before == nil {
t.Fatalf("reading the profile before the canonical write: %v", err)
}
for key, value := range map[string]string{
settingskeys.PlaybackAudioLanguage: `"de"`,
settingskeys.CatalogMetadataLanguage: `"fr"`,
settingskeys.PlaybackSubtitleLanguage: `"ja"`,
settingskeys.PlaybackSubtitleMode: `"always"`,
settingskeys.PlaybackShowForcedSubtitles: `false`,
} {
if _, err := store.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: key,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(value)); err != nil {
t.Fatalf("canonical write of %s: %v", key, err)
}
}
got := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if got.Language != "de" {
t.Errorf("language = %q, want %q", got.Language, "de")
}
if got.PreferredMetadataLanguage != "fr" {
t.Errorf("preferred_metadata_language = %q, want %q", got.PreferredMetadataLanguage, "fr")
}
if got.SubtitleLanguage != "ja" {
t.Errorf("subtitle_language = %q, want %q", got.SubtitleLanguage, "ja")
}
if got.SubtitleMode != "always" {
t.Errorf("subtitle_mode = %q, want %q", got.SubtitleMode, "always")
}
if got.ShowForcedSubtitles {
t.Error("show_forced_subtitles = true, want false")
}
// The legacy columns never moved: the canonical write is the only storage
// involved, which is precisely why reading the columns hid it.
after, err := store.GetProfile(ctx, "profile-1")
if err != nil || after == nil {
t.Fatalf("reading the profile after the canonical write: %v", err)
}
if !reflect.DeepEqual(before, after) {
t.Errorf("a canonical write moved the legacy columns:\n before = %+v\n after = %+v", before, after)
}
}
// TestListProfilesFallsBackToContractDefaults: a profile with neither a
// canonical row nor column data serves the contract's defaults, not the
// columns' schema defaults. subtitle_mode is the one that shows the
// difference is real — the column defaults to 'auto' and so does the
// contract, so show_forced_subtitles and the languages carry the assertion.
func TestListProfilesFallsBackToContractDefaults(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
got := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if got.Language != "" {
t.Errorf("language = %q, want the contract default \"\"", got.Language)
}
if got.PreferredMetadataLanguage != "" {
t.Errorf("preferred_metadata_language = %q, want the contract default \"\"",
got.PreferredMetadataLanguage)
}
if got.SubtitleLanguage != "" {
t.Errorf("subtitle_language = %q, want the contract default \"\"", got.SubtitleLanguage)
}
if got.SubtitleMode != "auto" {
t.Errorf("subtitle_mode = %q, want the contract default %q", got.SubtitleMode, "auto")
}
if !got.ShowForcedSubtitles {
t.Error("show_forced_subtitles = false, want the contract default true")
}
}
// TestListProfilesRoundTripsLegacyWrite: the legacy write path still works
// end to end. The columns are no longer read, so this only passes because the
// write mirrors into the canonical rows — which is the whole cutover shape,
// and the regression that would break every shipped client if the sync broke.
func TestListProfilesRoundTripsLegacyWrite(t *testing.T) {
store := newProfileTestStore(t)
handler := NewProfileHandler(testUserStoreProvider{store: store})
rr := updateProfileVia(t, handler, "profile-1",
`{"language":"es","preferred_metadata_language":"it","subtitle_language":"ko",`+
`"subtitle_mode":"off","show_forced_subtitles":false}`)
if rr.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
}
// The update response and the next list must agree; both serve resolution.
var updated profileResponse
if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
t.Fatalf("decoding update response: %v", err)
}
listed := profileFromList(t, listProfilesVia(t, handler), "profile-1")
if !reflect.DeepEqual(updated, listed) {
t.Errorf("update response and list disagree:\n update = %+v\n list = %+v", updated, listed)
}
if listed.Language != "es" {
t.Errorf("language = %q, want %q", listed.Language, "es")
}
if listed.PreferredMetadataLanguage != "it" {
t.Errorf("preferred_metadata_language = %q, want %q", listed.PreferredMetadataLanguage, "it")
}
if listed.SubtitleLanguage != "ko" {
t.Errorf("subtitle_language = %q, want %q", listed.SubtitleLanguage, "ko")
}
if listed.SubtitleMode != "off" {
t.Errorf("subtitle_mode = %q, want %q", listed.SubtitleMode, "off")
}
if listed.ShowForcedSubtitles {
t.Error("show_forced_subtitles = true, want false")
}
}
// TestListProfilesResolvesHouseholdInOneRead: the list serves several
// profiles, so it must not cost a store read each. It also pins that one
// profile's preference never leaks into another's.
func TestListProfilesResolvesHouseholdInOneRead(t *testing.T) {
ctx := context.Background()
base := newProfileTestStore(t)
if err := base.CreateProfile(ctx, userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
t.Fatalf("creating the second profile: %v", err)
}
for profileID, language := range map[string]string{
"profile-1": `"de"`,
"profile-2": `"ja"`,
} {
if _, err := base.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: settingskeys.PlaybackSubtitleLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: profileID,
}, json.RawMessage(language)); err != nil {
t.Fatalf("canonical write for %s: %v", profileID, err)
}
}
store := &countingResolutionStore{UserStore: base}
handler := NewProfileHandler(testUserStoreProvider{store: store})
resp := listProfilesVia(t, handler)
if got := profileFromList(t, resp, "profile-1").SubtitleLanguage; got != "de" {
t.Errorf("profile-1 subtitle_language = %q, want %q", got, "de")
}
if got := profileFromList(t, resp, "profile-2").SubtitleLanguage; got != "ja" {
t.Errorf("profile-2 subtitle_language = %q, want %q", got, "ja")
}
if store.reads != 1 {
t.Errorf("listing %d profiles issued %d resolution reads, want 1",
len(resp.Profiles), store.reads)
}
}
// countingResolutionStore counts the batched resolution reads a request makes,
// so a regression to one read per profile fails rather than merely slowing
// the list down.
type countingResolutionStore struct {
userstore.UserStore
reads int
}
func (s *countingResolutionStore) ListSettingValuesForResolution(
ctx context.Context, query userstore.SettingResolutionQuery,
) ([]userstore.SettingValue, error) {
s.reads++
return s.UserStore.ListSettingValuesForResolution(ctx, query)
}
+2 -3
View File
@@ -1499,7 +1499,7 @@ func (h *SectionHandler) sectionPresignURL(r *http.Request, path string, variant
}
// maybeInjectNextUp injects a SectionNextUp entry after SectionContinueWatching
// if the user's next_up_mode setting is "separate".
// if the profile's ui.next_up_mode setting resolves to "separate".
func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []sections.ResolvedSection, userID int) []sections.ResolvedSection {
if h.StoreProvider == nil || userID <= 0 {
return resolved
@@ -1508,8 +1508,7 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
if err != nil {
return resolved
}
mode, _ := store.GetSetting(ctx, "next_up_mode")
if mode == "separate" {
if sections.NextUpMode(ctx, store, apimw.GetProfileID(ctx)) == sections.NextUpModeSeparate {
return injectNextUpSection(resolved)
}
return resolved

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