Commit Graph
78 Commits
Author SHA1 Message Date
QuickandGitHub 8bde6f1156 feat(admin): improve responsive dashboard and settings (#539) 2026-08-04 11:43:50 -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
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
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
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
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
rxwatcherandQuick f0bc170113 feat(admin): clarify AI service configuration 2026-07-28 21:49:41 -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
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
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
QuickandGitHub 5e2dd91d6b Merge pull request #456 from Silo-Server/feat/admin-settings-contract
fix(admin): enforce settings contracts end to end
2026-07-23 16:00:55 -04:00
Quick104 920629e0ad fix(admin): address settings contract review findings 2026-07-23 15:14:52 -04:00
Quick104andClaude Fable 5 0834edcfc0 style(web): keep uuid formatting within 100-char line width
Addresses CodeRabbit review on PR #458.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:58:36 -04:00
Quick104andClaude Fable 5 82379fa3e2 fix(web): make Add Section work on insecure (plain-HTTP) origins
Both add-section paths on Settings > Home Screen called crypto.randomUUID()
unguarded. Browsers only expose randomUUID in secure contexts, so on
self-hosted servers accessed over plain HTTP the click handler threw
synchronously and the Add section button appeared dead while Cancel still
worked. api/client.ts and plexAuth.ts already carried ad-hoc fallbacks for
the same problem; extract a shared lib/uuid helper (UUIDv4 via
crypto.getRandomValues, available in insecure contexts) and use it at all
four call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:51:43 -04:00
Quick104 3c56ed606c fix(admin): enforce settings contracts end to end 2026-07-23 11:24:55 -04:00
rxwatcherandClaude Fable 5 bd97f3c123 fix(web): return a cleanup from the preload-error installer
Review feedback: window is shared across vitest tests, so repeated
installs accumulated listeners. The installer now returns a remover and
the tests detach in afterEach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:43:57 +02:00
rxwatcherandClaude Fable 5 8dc2dcbc13 fix(web): reload once when a deployed chunk fails to import
After a deploy, an open tab still references the previous build's
content-hashed chunks and the first lazy navigation dies with "Failed to
fetch dynamically imported module". Handle Vite's vite:preloadError by
reloading onto the current build, guarded to at most one reload per
minute so a persistently missing chunk cannot reload-loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:43:57 +02:00
Quick104 75c2260897 Merge remote-tracking branch 'origin/main' into feat/client-diagnostics-server 2026-07-21 08:17:24 -04:00
84f8495e4f feat(web): collapse per-scan rows on the admin Libraries page (#436)
* feat(web): collapse per-scan rows on the Libraries page

Bulk single-file scans (e.g. autoscan picking up a season drop) rendered
one full-width row per scan under the library, each with its own stop
button that actually cancelled every scan for the library. With 70+
queued files the table became an unusable wall of paths.

Each library's scans now collapse into one summary line (N running ·
N queued) with a single cancel control, compact rows for the first few
scans (file basename, full path on hover, progress for running ones),
and a Show all expander capped to a scrollable height. The Scan Queue
popover gets the same running-first collapse per library group, shows
basenames instead of full paths, and caps its staggered fade-in delay
so deep rows don't appear seconds late.

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

* fix(web): address scan-collapse review feedback

- Keep the 'Entire library' target visible on pathless (full-library)
  scan rows in the inline active-work list.
- Drop the redundant '+ N more' button in LibraryScanTasks; the header
  'Show all N' toggle is the single expand control there.
- Extract useCollapsedScans so the table rows and Scan Queue popover
  share one collapse implementation, and always derive the popover
  target from formatActiveScanTarget instead of a hardcoded fallback.
- Update the active-scan progress test for the split mode/target markup.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:26:35 -04:00
Quick104andClaude Fable 5 c5a5b02221 feat(web): admin client-diagnostics viewer
List/detail/download/delete for uploaded diagnostic reports at
/admin/diagnostics, beside the Logs viewer: server-filter parity with the
admin API, cursor pagination, manifest + device summary detail sheet,
playback-session links into the filtered logs view, presign/stream
download handling, and feature-state banner. Extracts a shared
authenticated apiResponse() helper used by api() and apiDownload().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct
2026-07-20 13:53:27 -04:00
95779c47a9 fix(web): update metadata for selected version (#414)
* fix(web): update metadata for selected version

* refactor(web): simplify selected duration lookup

* refactor(web): share the hero runtime formatter via mediaFormat

MovieContent and EpisodeContent carried identical local formatDuration
helpers; move the minutes-based badge formatter into lib/mediaFormat as
formatRuntimeMinutes alongside the other canonical media-spec helpers.

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

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:08:16 -04:00
0f9584ef42 fix(overlays): derive dynamic-range badges from Dolby Vision metadata (#365)
* fix(overlays): derive dynamic-range badges from Dolby Vision metadata

Dolby Vision files were labeled with a generic "HDR" badge everywhere
outside the Media Info dialog, because badge sites only consulted the
bare FileVersion.hdr boolean even though the payload carries
video_tracks[].dolby_vision / dv_profile / video_range_type /
color_transfer.

Web: add a shared helper (web/src/lib/videoRange.ts) that derives a
display label ("DV", "DV HDR10", "DV HLG", "HDR10+", "HDR10", "HLG")
from the probed video tracks, mirroring the server-side normalizeHDR
vocabulary, with the hdr boolean kept as the last-resort fallback for
stale pre-DV probe rows. Use it in QualityBadges, VersionDropdown,
VersionFlyout, and the player HUD (playback-info) in place of the
hardcoded "HDR" literal. Badge styling is unchanged.

Server: in internal/overlays/summary.go, break bestFile resolution
ties by richness of dynamic-range metadata (DV > explicit HDR10/HLG
via color_transfer > bare hdr boolean > SDR) so a first-scanned
generic-HDR file no longer masks a Dolby Vision sibling on card
overlays. Full ties still keep the earliest file, so the selection
stays deterministic.

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

* fix(overlays): detect Dolby Vision via dv_profile and DOVI range type

Probed rows set DolbyVision and DVProfile together, but catalog-seeded
or imported tracks can carry only dv_profile or a DOVI* video_range_type
with an empty dolby_vision string. Share one hasDolbyVision predicate
between rangeRank and normalizeHDR so those rows rank and label as DV,
matching the web videoRange helper.

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

* fix(overlays): read video_range_type and hdr10_plus in hdrTypeFromTracks

Review follow-up: hdrTypeFromTracks only inspected color_transfer, so
server card overlays could never label HDR10+ and a track whose only
signal is video_range_type (e.g. catalog-seeded rows without probed
color metadata) fell through to the bare-boolean tier of rangeRank —
inconsistent with hasDolbyVision, which already reads the range type.
The server now shares the web helper's detection order exactly, making
the web/server vocabulary mirror claim true.

Also drops the unused video_range field from the web VideoRangeTrack
interface.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-16 10:53:30 -04:00
f377be9d6e feat(catalog): include per-episode overlay summaries on season episode listings (#364)
* feat(catalog): include per-episode overlay summaries on season episode listings

Episode cards already render user-configurable overlay badges everywhere
except the series/season detail pages: the season-episodes endpoint never
included overlay_summary, so SeasonEpisodeGrid and EpisodeRow had nothing
to render.

Server: add overlay_summary (omitempty) to episodeResponse and populate it
in buildEpisodeResponses via overlays.BuildSummary over the episode's
already-loaded media files, access-filtered with FilterMediaFilesByAccess
to match the browse/sections paths. No extra queries; the files were
already batch-fetched for the files[] payload. Additive-only API change.

Web: extend EpisodeListItem with overlay_summary, add
overlayDataFromEpisodeListItem (shared extract helper), and render
CardOverlays (variant="wide") on the episode stills in SeasonEpisodeGrid
and EpisodeRow using useOverlayPrefs, matching the ContinueWatchingCard
pattern. The hook already honors the admin kill switch and per-user prefs.

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

* fix(catalog): apply library restrictions in FilterMediaFilesByAccess without a quality ceiling

FilterMediaFilesByAccess short-circuited whenever MaxPlaybackQuality was
empty, skipping the AllowedLibraryIDs/DisabledLibraryIDs checks that
FileAllowedByAccess enforces. For viewers with library restrictions but
no quality ceiling, callers (episode listings, browse/section overlay
summaries, item detail versions) received files from restricted
libraries. Short-circuit only when no access criteria are set at all.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:37:34 -04:00
035ef457a4 fix(web): derive settings language options from canonical language list (#363)
The profile and device settings language dropdowns (preferred subtitle
language, spoken language, metadata language, device audio language, and
per-library audio/subtitle overrides) were built from two hardcoded
12-language lists, so Dutch and 24 other languages supported by the
canonical list in languageNames.ts could not be selected and stored codes
like "nl" rendered as raw codes instead of names.

Derive both settings option lists from the shared LANGUAGES array and
route getLanguageLabel through getLanguageName, so every supported
language is selectable everywhere and stored values always render a
proper display name (e.g. "nl" -> "Dutch"). Options are now alphabetized
by label, matching the other language dropdowns that already use
LANGUAGES; the "No preference" sentinel stays first.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:37:00 -04:00
c1c110e3d2 feat(playback): improve web subtitles and track selection (#362)
* fix(player): keep text subtitles in sync across copy-mode restarts and sparse cue windows

- Rebase already-loaded cues in place when streamOriginSeconds changes
  (copy-mode session restart) instead of leaving them offset by the delta.
- Stop inferring end-of-input from where a window's cues stop; only the
  known media duration marks EOF, so a dialogue gap no longer silently
  ends prefetching for the rest of playback.
- Anchor the first window fetch to the intended start position (resume
  target or pending seek) while the element still reports currentTime=0,
  and reset coverage on forward seeks past the fetched window.

* fix(playback): align encoded transcode start with the declared segment boundary

A mid-segment start (resume, seek restart, audio switch) spawned ffmpeg at
the raw seek position while labeling its first segment with the grid number,
whose synthetic-manifest start is up to one segment earlier. hls.js aligns
the first fragment's content to that declared position, shifting the whole
session's timeline late by seek mod segment_duration (0-2s): subtitles
trail dialogue by a constant per-session offset and progress/resume
positions drift by the same amount.

Snap the ffmpeg start position down to the segment boundary for encoded
sessions so declared and produced timelines match exactly; the player
still seeks to the precise requested position. Copy-mode sessions serve
ffmpeg's real manifest and keep the raw seek.

* fix(api): forward http.Flusher through response-writer middleware wrappers

Streamed subtitle extracts (and any progressive response) flush per chunk
via an http.Flusher assertion, but none of the status-capturing middleware
wrappers implemented Flush, so the assertion failed and cues sat in Go's
response buffer until ffmpeg finished. On large remuxes where a 600s window
takes 20s+ to demux, captions appeared only when the whole window completed
instead of within the first seconds.

Give every wrapper a Flush() (satisfies plain assertions, including chi's
Compress) and Unwrap() (satisfies http.ResponseController). The jellycompat
image-proxy tag rewriter flushes only in passthrough mode since it buffers
JSON bodies for rewriting. Regression test asserts the API chain forwards
Flush end to end.

* feat(player): PGS subtitles honor size, position, and background settings

Port the tvOS/iOS bitmap-cue styling to the web player. libpgs now decodes
in worker mode but draws to a hidden source canvas on the main thread; a
compositor detects cue regions from the frame's alpha channel and re-places
them on a visible overlay canvas per the shared subtitle appearance
settings: size scale (with the 0.85 authored-size compensation), vertical
position preset (dialogue-band cues only — floating signs keep authored
placement, matching the Apple implementation), and the background box.
Font family, text color, and outline are baked into the source pixels and
remain inapplicable.

* fix(player): anchor PGS position presets to the text overlay's reference frame

The initial port used silo-apple's 30/1080 bottom margin and video-relative
lower-third/top anchors; the web text overlay anchors to a 16:9 reference
frame with 7%/18% offsets that extends into the letterbox for wide content.
Use the same anchors so PGS dialogue lands exactly where SRT text does.

* feat(player): size PGS cues to match the text subtitle line height

Replace the authored-size ladder (0.85 × font-size ratio) with per-cue
text-line matching: the region detector reports the tallest text line inside
each cue, and the compositor scales the cue so one line of bitmap text
renders at the same pixel height as the SRT overlay's font at the current
preset. Authored size differences between discs no longer leak through;
upscaling is capped at 2.5× to keep small bitmaps from going blurry.

* feat(subtitles): opt-in windowed PGS extraction to cut mid-file load latency

PGS extracts always demuxed the source from byte 0, so starting a large
remux mid-file meant minutes before the first bitmap cue. The web player
now opts in to windowed extraction (?windowed=1&position=&duration=) and
re-points libpgs at a fresh window on seeks and near coverage end; ffmpeg
input-side -ss with -copyts keeps absolute source timestamps. Without the
explicit opt-in the endpoint behaves byte-identically, so Apple/Android
and other single-fetch consumers are unaffected. ASS remains
unconditionally non-windowed (its header only exists at offset 0).

* feat(playback): cache extracted PGS subtitle tracks

Every selection of an embedded PGS track re-ran a full ffmpeg extract
that demuxes the entire source file from byte 0 — minutes for a large
remux — and responses were Cache-Control: no-store, so repeat
selections, re-watches, and multiple viewers all paid full price.

Add a disk cache for full-track .sup extracts under
<transcode_dir>/subtitle-cache, created lazily:

- Keyed by source path hash + subtitle stream ordinal + source
  mtime+size (encoded in the filename), so a replaced source file
  implicitly invalidates its entries; the source is stat'ed on every
  lookup.
- Cache miss: ffmpeg stdout is teed to the response (first viewer
  still streams progressively, first-byte latency unchanged) and into
  a temp file that is fsynced and atomically renamed into the cache
  on clean ffmpeg exit. Any error — ffmpeg failure, client disconnect,
  tee write failure, or the source changing mid-extract — discards
  the temp file, so a partial entry is never served.
- Cache hit: served via http.ServeContent (Range support,
  Content-Length, Last-Modified from the source mtime) with a
  revalidatable Cache-Control instead of no-store.
- Concurrent requests for the same in-flight track run their own
  uncached extract (mutex + in-flight key set) rather than blocking
  on another client's connection.
- Scan-on-commit LRU eviction under a 2 GiB cap (recency tracked by
  bumping entry mtime on hit; atime is unreliable under relatime),
  plus sweep of crash-orphaned .part temp files.
- Windowed PGS requests (?windowed=) bypass the cache in both
  directions: their output covers only a slice of the track.

Both the integrated API handler and the standalone proxy subtitle
path share the same playback.SubtitleCache.ServeSUPExtract helper.
VTT (already windowed and fast) and ASS (small) stay uncached. No
API surface change.

AI-use disclosure: implemented with Claude Code.

* fix(playback): check Close error returns in subtitle cache paths

Silence errcheck on the cache-hit defer and the test's simulated
disk-full Close.

AI-use disclosure: implemented with Claude Code.

* feat(playback): warm PGS cache in background and window from cached track

Windowed PGS requests bypassed the cache entirely, so every window fetch
re-demuxed the multi-GB original file. Now a windowed miss kicks off a
detached background warm (full-track extract into the cache, at most 2
concurrent server-wide, coalesced with client-driven fills), and once the
entry exists windowed extracts read the 15-80MB cached .sup instead —
seeks and re-enables become near-instant after the first load. Verified
empirically that ffmpeg preserves absolute PTS when windowing a sup input.

* feat(player): hold playback while PGS subtitle cues load

When a PGS track is enabled (or a seek lands outside the fetched window),
extraction takes seconds and dialogue could play unsubtitled. The player
now pauses until the renderer's parsed data covers the playhead — tracked
via libpgs' parsed-timestamp watermark, the exact predicate it renders
by — showing a "Loading subtitles…" indicator after 500ms. User
play/pause always wins over the hold, a 20s safety timeout prevents
stranding playback, and background prefetch never pauses. If future
libpgs versions reshape the observed internals the hook degrades to the
old play-through behavior.

* perf(player): shrink uncached PGS window to 600s

Draining a windowed extract from the source reads the full interleaved
container across the window (~1GB per 100s of remux on measured
hardware); a 3600s window cost ~12GB of reads per fetch while cold. Once
the server cache is warm a window costs milliseconds regardless of size,
so smaller windows only add trivially cheap re-fetches.

* feat(subtitles): burn in PGS/bitmap subtitles for the web player

The web player rendered PGS client-side via libpgs, which required
extracting the .sup track — a cold ffmpeg demux that took seconds even
windowed, since c:s copy still reads the whole interleaved container up
to the playhead. Every other server (Plex, Jellyfin default, Emby) burns
image subtitles into the video instead, and that is the only path with
no per-seek extraction cost.

Selecting a bitmap subtitle (PGS/DVD/DVB) now restarts the transcode
with subtitle_burn_in at the current aligned position, reusing the same
restart machinery as an audio/quality switch so the segment-boundary
timeline alignment holds. The server composites the decoded subtitle
onto the video with an overlay filter_complex graph (libass's subtitles=
filter is text-only); overlay runs at native resolution before any
target scaling, and hardware pipelines round-trip through CPU like the
text path. Burn-in forces a video encode, so copy-video recipes are
upgraded to h264 both client- and server-side.

Text subtitles keep the instant, styled, client-side path. The .sup
streaming endpoints, cache, and windowing are retained for the Apple
client, which renders PGS natively. The now-dead web PGS stack
(usePGSSubtitles, pgsPlacement, libpgs dep) is removed.

Tradeoff: bitmap subtitles no longer honor web appearance settings
(baked into the video) and toggling one restarts the transcode
(~1-2s buffering), matching Plex behavior.

* fix(player): rebuild text subtitle track when turning off PGS burn-in

Selecting an SRT track that turned off bitmap burn-in restarted the
transcode, and the client TextTrack built in that same moment was
orphaned when the <video> element reloaded, so the subtitles never
rendered (and a seek could not recover the dead track). Rebuild the
text track once the new stream settles, gated on the burn-in-off
transition so quality/audio switches and copy-mode seek restarts keep
their subtitles without a needless re-extract.

* fix(player): render web subtitles behind the control HUD

The text subtitle overlay sat at z-20, above the controls layer (z-10),
so cues painted over the bottom HUD and cluttered the control bar. Drop
it to z-[5] — above the video, below the controls — so the HUD paints
over the cues while it is visible. When controls are hidden the whole
controls layer is opacity-0, so cues remain fully visible.

* feat(player): lift web subtitles above the control bar while it's visible

Rather than hiding bottom-anchored cues behind the HUD, raise them just
above the control bar (measured height + a small gap) whenever the bar
is visible in the foreground player, then settle them back when it
hides. The bar is a roughly fixed pixel height while the cue offset
scales with the player, so the bar is measured via ResizeObserver
rather than hardcoded. Top-anchored cues never collide with the bottom
HUD, so they stay put. z-[5] is retained as a safety so any residual
overlap tucks behind the bar.

* fix(player): coalesce same-tick transcode restarts into one dispatch

Starting playback with a persisted bitmap subtitle fired transcode/start
twice within milliseconds: the auto-start effect dispatched before
subtitle auto-selection restored the burn-in, whose effect then forced a
second start. The first request was already on the wire (no abort signal
was passed to fetch), so the server spawned an ffmpeg only to kill it
for the second start — visible in production as an ffmpeg exit error
~1ms after every such session start, and slowing time to first frame.

Defer the network dispatch by one macrotask so back-to-back restart
calls in a tick collapse into a single request carrying the final
parameters; state updates stay synchronous. Pass the abort signal into
playerFetch so a superseded in-flight request is actually cancelled,
and drop any deferred dispatch on unmount so a stray transcode/start
cannot land after the session's exit DELETE.

* fix(catalog): resolve effective subtitle defaults for movie item details

Movie pre-play subtitle selectors were missing the effective defaults
(including per-item overrides saved from a previous play) that episodes
and watch payloads already resolve. Extract applyToItemDetail/
applyToWatchDetail helpers and apply defaults for movies in
buildMediaItemDetail. The SubtitlesPopover now also eagerly loads
downloaded subtitles when the saved preference points at one so the
closed trigger's Auto summary reflects the override.

* feat(player): scale subtitle font size with the rendered video

Replace fixed rem font sizes with px values defined at a 720px 16:9
reference height, scaled proportionally with the actually-rendered
video (object-fit: contain) so subtitles keep the same relative size
as the window grows or shrinks, with a 12px legibility floor. Rename
useSubtitlePositionStyle to useSubtitleLayout, returning both the
position style and the font scale, and add unit tests for the
appearance helpers.

* fix(player): satisfy strict index checks in transcode quality test

* feat(player): let the pre-play Auto option clear the saved subtitle override

A manual in-player subtitle selection persists as an 'always' override
for that movie/series, but nothing in the UI could undo it — auto
selection stayed pinned to the chosen track forever. Choosing 'Auto' in
the pre-play subtitles popover now also deletes the stored preference
(movie content ID / episode series ID) and invalidates item details so
profile-level auto selection applies again.

* feat(player): persist pre-play subtitle selections as the item override

Choosing a track (or Off) in the pre-play subtitles popover only lived
in component state: it applied to that playback session but vanished on
returning to the detail page. Persist it through PUT /subtitle-prefs —
the same 'always'/'off' override a manual in-player selection saves —
keyed by movie content ID or episode series ID, and invalidate item
details so the effective defaults reflect it immediately.

* feat(ui): show the saved subtitle override and richer pre-play pill summaries

A stored per-item override displayed as 'Auto: <language>', hiding both
that an override exists and which track it is. The pre-play subtitle
pill now shows the resolved track directly (name with (SDH)/(Forced)
markers plus format, skipping markers the name already carries), the
matching list row gets the checkmark instead of Auto, and the Auto row
reads 'Reset to profile defaults'. Subtitle, audio, edition, and
version pill summaries also truncate much later (max-w-44/sm:max-w-64).

* fix(player): recover text subtitles from stream reloads and failed window fetches

Three failure modes could silently freeze or stop web text subtitles:

- A stream restart (seek-triggered transcode restart, quality/audio
  switch) reloads the <video> element and can orphan the programmatic
  TextTrack — cuechange stops firing and the last cue freezes on screen.
  Only the PGS-burn-in-off transition rebuilt the track. Now every
  settled stream URL change bumps the generation, and the rebuild
  carries loaded cues (converted back to source time) and window
  coverage over so it costs no refetch.
- The sliding-window fetcher committed windowEnd before the fetch ran,
  so a failed or hung window counted as covered and was never retried —
  subtitles silently stopped for up to 10 minutes. Coverage now commits
  only after the window streams in fully; failures leave the range
  uncovered and retry after a 5s backoff.
- A hung extraction (one fetch in flight at a time, no deadline) blocked
  every future window for the session. Reads now arm a 30s stall timer
  that aborts a response which stops delivering chunks; slow-but-
  progressing streams keep resetting it.

Diagnosed from a session where ffmpeg took 69s to stream one subtitle
window and a transcode restart landed mid-fetch, freezing the active cue.

* fix(playback): keep subtitle selections stable across file changes

* fix(web): clarify subtitle labels and positioning

* fix(player): hide HUD when pointer leaves

* fix(web): tidy subtitle track badges

* fix(playback): remap audio tracks across file versions

* fix(web): tidy audio track labels

* docs(playback): clarify bitmap subtitle appearance

* fix(playback): preserve selection state on restart

* fix(http): preserve response state across flushes

* fix(web): preserve pending quality for burn-in

* fix(playback): preserve subtitle inventory identity

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-10 08:21:26 -04:00
d68e70bb47 feat(autoscan): Sonarr/Radarr webhook intake without arr API keys (#353)
* docs(autoscan): add arr webhook intake spec and implementation plan

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

* feat(autoscan): add webhook intake schema migration

Adds delivery_mode to autoscan_sources, the autoscan_webhook_endpoints
table, and delivery_mode/provider_event_type on autoscan_events.

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

* feat(autoscan): add built-in arr-webhook source identity

Host-discovered scan-source entry so webhook-mode sources need no
plugin installation; composite lister appends it to plugin discovery.

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

* feat(autoscan): persist delivery mode, webhook endpoints, event metadata

Sources carry delivery_mode; autoscan_webhook_endpoints CRUD with
SHA-256 token lookup and AAD-bound encrypted redisplay; events record
delivery_mode/provider_event_type; CreateEvent gains SkipRunningCheck
so webhook deliveries are never dropped by the poll exclusion.

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

* feat(autoscan): share the consume path and add webhook IngestChanges

Extracts consumeSourceChanges from PollOnce (marker semantics
preserved, existing poll tests unchanged); PollOnce skips webhook
sources; IngestChanges feeds deliveries through the shared pipeline
without markers and without the running-event exclusion.

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

* feat(autoscan): add Sonarr/Radarr webhook payload parser

Host-side arrwebhook package: provider inference, import/rename/delete
path extraction with vanished-path-friendly previous paths, subtree
fallback, exact-path dedupe, and no-op unknown events. Fixture-backed.

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

* feat(autoscan): add public webhook delivery route and admin endpoint management

Public POST /api/v1/autoscan/webhooks/{token} with per-IP rate
limiting, 256KiB body cap, 202-for-noop semantics, and token/body kept
out of logs; admin create/rotate/delete endpoint routes; source
responses carry delivery mode + webhook status/URL; create/update
validate delivery mode against source identity.

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

* feat(web): add webhook delivery mode to Autoscan admin UI

Webhook sources get a generate/copy/rotate webhook URL section,
provider selector, delivery status, and a connection-free Add-source
flow; activity rows badge webhook deliveries with the arr event type.
Path rewrites stay editable in both modes.

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

* fix(api): redact secret path params from request and activity logs

The request logger and activity-log middleware recorded raw URLs, so
bearer credentials in secret path segments (autoscan webhook {token},
webhook-sync {secret}) were persisted to app logs and activity_log.
Redact the secret segment via the chi route params in both sinks.

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

* fix(autoscan): make webhook delivery reliable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:13:31 -04:00
e96a8a0cf8 feat(search): binary-quantized embedder vectors — optional, default-on for fresh installs (#351)
* feat(search): binary quantization setting for the Meilisearch embedder

New server setting catalog.search.meilisearch.binary_quantized
(default false) threads into the embedder index settings
("binaryQuantized": true) and into the schema-version hash, so flipping
it closes the sync gate and mandates a rebuild in both directions —
Meilisearch cannot de/re-quantize an index in place.

With 3072-dimensional embeddings this cuts vector storage ~32x
(≈12KB → 384B per document), keeping the whole vector store in page
cache: rebuilds and hybrid queries get sharply cheaper. Hybrid search
(keyword + semantic) cushions the small relevance cost of sign-only
vectors.

The hash token is appended only when the flag is set, so indexes built
before this change keep their schema version while it stays off.

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

* feat(search): binary quantization default-on for fresh installs + admin toggle

- Migration seeds catalog.search.meilisearch.binary_quantized=true only
  when no active catalog search index exists. Existing deployments stay
  unset (= off): flipping quantization changes the index schema-version
  identity, which closes the incremental-sync gate until a full rebuild
  runs — that must never happen implicitly on upgrade. Fresh installs
  have no index yet, so their first rebuild simply starts quantized.
- Search settings page gains the toggle with an explicit
  "requires a full index rebuild" warning, a status row, and settings-
  search keywords.

Prod benchmark (607.9k docs, 3072-dim vectors, N=10 medians, replicated):
hybrid 0.5 unchanged (7.5ms float vs 8.0ms quantized, within ±2ms
keyword-control jitter); pure semantic 9ms → 4ms; on-disk index 18G →
8.3G; rebuild duration unchanged.

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

* fix(search): address review feedback on binary-quantized embedder

- Add catalog.search.meilisearch.binary_quantized to the restart-required
  registry. The provider freezes BinaryQuantized into MeilisearchProviderConfig
  at construction, so without this a toggle-then-rebuild in the same process
  builds a quantized schema while the live provider still compares against the
  old value and falls back until restart (Codex P2).
- Validate binary_quantized in HandleUpdateSetting, mirroring semantic_enabled.
  A raw API write of a non-bool previously persisted unnormalized, then failed
  CatalogSearchSettingsFromMap on load and silently reverted the entire search
  config to Postgres defaults.
- Gate the binary_quantized token in the schema-version identity on
  semanticEnabled: with semantic off the index has no embedders, so the flag
  has no on-index effect and must not force a pointless rebuild. Stays
  byte-identical to a pre-flag index. Covered by a new test.
- Clarify the seed migration comment (guard is "no active index", which also
  covers Meilisearch-configured-but-never-indexed deployments) and the UI hint
  (~30x smaller raw vectors, index roughly halves; only applies with semantic).

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:55:51 -04:00
0694787504 fix(overlays): show_status persistence + card overlay layout fixes (#335)
* fix(metadata): persist series show_status from provider metadata

Plugin-reported series status (proto status field 31) was mapped into
MetadataResult.ShowStatus but dropped by both metadataResultToItem and
itemToMetadataResult, so media_items.show_status stayed empty for every
movie and series - only the manga enrichment path ever wrote it. This
left the Show Status card overlay permanently blank for series.

- carry ShowStatus through both converters; series values normalize to
  a canonical lowercase domain (returning/ended/cancelled/in_production/
  upcoming) so TMDB "Returning Series"/"Canceled" and TVDB
  "Continuing"/"Upcoming" converge on one spelling
- pass non-series values through verbatim so the manga status domain
  ("Ongoing", ...) can never be mangled by a generic refresh round-trip
- round-tripping the existing item's status also stops refreshes from
  wiping a previously persisted value via show_status = EXCLUDED.show_status
- extend the web overlay formatter with continuing/upcoming/planned

TMDB/TVDB plugins need follow-up changes to actually emit the status
field; prepped separately in their repos.

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

* fix(web): overlay badge layout, ordering, and wordmark rendering

Fixes from a full card-overlay audit (every issue verified by DOM
geometry measurement before/after):

- render each card edge as one flex row holding both corner stacks so
  opposing badges share the width (min-w-0 + truncate) instead of
  overlapping on narrow cards; long labels ellipsize instead of
  wrapping over the opposite corner
- honor prefs.order via orderedOverlaysForPosition — the renderer
  previously ignored the stored order entirely
- cap corners at 3 badges so maxed-out configs can't collide with the
  opposite vertical corner
- lift bottom-right badges above the card menu button, which is always
  visible on touch devices and occluded them
- suppress the text label when a wordmark icon (HDR10/ATMOS/AV1/HDR)
  already spells it — pill/vibrant presets rendered "HDR10 HDR10" —
  and widen the wordmark viewBoxes, which clipped their own text;
  drop the never-used iconOnly flag the wordmark rule supersedes
- standalone resolution badge now uses prettyResolution ("4K", not
  "2160P"), matching the combined badge
- manga cards skip generic overlays (their status/count chips own both
  top corners) and the two chips now share a row and truncate instead
  of overlapping each other
- useOverlayPrefs returns null while loading so cards no longer flash
  default badges before the user's config or admin kill switch arrives
- settings rows for Resolution/HDR now say why they're hidden while
  the combined badge is enabled
- add a CardOverlays test suite covering every registered overlay,
  ordering, suppression, wordmarks, the corner cap, and menu clearance

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:16:38 -04:00
43d9056b01 fix(collections): repair broken builtin collection templates (#331)
* fix(collections): repair broken builtin collection templates

A live audit of the builtin template catalog (all 40 MDBList URLs and all
10 TMDB franchise IDs fetched) found two dead sources, a silent bundle-apply
collision, and several templates whose defaults contradict their descriptions:

- Repoint mdblist_misc_a24 and mdblist_misc_criterion_collection to live
  lists; the original irvingbeano/shtluck lists were deleted on MDBList
  (404), so every sync of those collections failed.
- Retitle mdblist_charts_popular_movies to "IMDb MovieMeter Top 100". It
  shared the "popular-movies" title slug with tmdb_popular_movies, and
  bundle apply dedupes by slug per library, so applying all_defaults
  silently skipped it. Poster regenerated from the raw plate with the new
  title; new handler test asserts builtin title slugs stay unique.
- Raise the shared default limit 50 -> 100, give the IMDb Top 250 templates
  an explicit 250 (limit*4 fetch trim previously never scanned entries
  201-250), and drop the limit on catalog lists (Criterion, A24) so they
  hold every owned title.
- Correct IFC Films to MediaMovie (live list is 100% movies; as MediaMixed
  it was offered to TV libraries where it always synced empty) and fix the
  Trakt Popular descriptions (ratings-based, not "most-watched").
- Update stale limit docs in collection-templates.md.

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

* fix(collections): raise import limit caps above IMDb Top 250 default

The IMDb Top 250 templates now default to 250 items, but the template
config forms rendered their Max Items input with max=200 and the user
import API rejected limits above 200, so applying those templates from
the direct galleries failed native validation or got a 400.

Raise the cap to 500 on both sides, wired to shared constants: sync's
fetch trim (collectionSourceFetchMax) never scans more than 500 source
entries, so a larger explicit limit could never be satisfied anyway.
collectionutil.MaxExplicitItemLimit backs validateOptionalLimit, and
COLLECTION_MAX_ITEMS in lib/collectionTemplates backs all seven Max
Items inputs (gallery forms + admin import/editor dialogs).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:46:50 -04:00
af0e3e75a9 feat(web): Media Info dialog + consolidated media-spec formatters (#323)
* refactor(web): consolidate media-spec formatters into lib/mediaFormat

Media-spec formatting was duplicated with divergent behavior across six
modules: versionFormatUtils (GB/"" fallback), playback-info (GiB/"—"
fallback, CODEC_LABELS), overlay tech.ts (prettyResolution,
compactHdrSuffix), versionRankingUtils (mapAudioLabel), plus private
formatBitrate copies in ItemCard and useTranscodeQuality.

One canonical module now owns these helpers; the deliberate per-surface
display differences (fallback text, GB vs GiB labels) are parameters
instead of copies. All call sites migrated, including two cross-boundary
imports (adminActivityPresentation reaching into @/player/playback-info,
player components reaching into page-level utils). The quality-menu Mbps
label in useTranscodeQuality stays local as a distinct display policy,
renamed formatQualityBitrate. Also removes the unused TrackSection/
TrackRow components and renames versionFormatUtils.tsx to .ts.

Behavior-preserving: playback-info.test.ts and VersionFlyout.test.ts are
unmodified and assert exact rendered strings ("7.1 GiB", "640 kbps",
"Dolby Vision Profile 8.1 (HDR10)").

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

* feat(web): add Media Info dialog with detailed per-file technical specs

Adds a curator/admin-gated "Media Info" dialog to movie and episode
detail pages showing full technical specs per file: per-stream bitrates,
Dolby Vision profile with base-layer compatibility and EL flags, HDR10+,
chroma subsampling derived from pixel format, color primaries/transfer/
space, bit depth, codec profile/level (H.264, HEVC, and AV1 level
decoding), audio layouts/sample rates, and subtitle stream details.

Frontend-only: the item-detail response already serialized all of this;
VersionVideoTrack in api/types.ts was just missing the DV/HDR10+/
video_range_type fields the backend sends. Entry points: a "Media Info"
item in the detail-page overflow menu (opens on the selected version)
and a per-row info button in Media locations (opens on that file). All
versions render as accordion sections with the target expanded.

Spec rows are built by pure section builders in mediaSpecSections.ts
with fixture tests covering SDR/HDR10/DV 7/DV 8.1/HDR10+/multi-track/
external-subtitle cases; the dialog is a thin renderer over them.

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

* fix(web): recover DV compatibility detail from video_range_type

Review follow-ups on the Media Info dialog's dynamic-range labels:

- When ffprobe omits dv_bl_signal_compatibility_id, the scanner still
  derives video_range_type (DOVIWithHDR10/HLG/SDR) from color_transfer.
  formatDolbyVisionLabel now falls back to that enum for the
  compatibility detail instead of showing a bare "Dolby Vision
  Profile 8"; explicit DV side-data fields still win when present.
- formatVideoRangeLabel checks the video_range_type mapping before the
  standalone HDR10+ shortcut, so DOVIWithHDR10Plus tracks without a raw
  dolby_vision string keep their Dolby Vision hint.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:50:11 -04:00
e140bd9424 feat(metadata,scanner): trailers and extras for movies and series (#322)
* feat(metadata,scanner): trailers and extras for movies and series

Remote provider videos (TMDB trailers/teasers/featurettes/...) are fetched
through the unified match/refresh pipeline into the new item_videos table,
filtered per-library via media_folders.trailer_kinds, merged across
providers with site/provider dedup, and lockable via FieldVideos.

The movie scanner stops discarding supplemental directories (Trailers/,
Featurettes/, Behind The Scenes/, ...) and classifies them — plus
Jellyfin-style filename suffixes (-trailer, -behindthescenes, ...) and
series-root supplemental dirs — into the new media_extras entity backed by
ordinary media_files rows (extra_id ownership, content_id/episode_id NULL so
existing version/matching queries stay structurally blind to extras).
Series Extras/SxxExx season-0 mapping is unchanged. Extras are playable
watch targets via a GetWatchDetail fallback tier (episodes precedent),
with contentid.ForLocal minting stable ids.

API: ItemDetail gains additive videos/extras arrays (single + batch parity);
library settings expose trailer_kinds. jellycompat now populates
RemoteTrailers, LocalTrailerCount/SpecialFeatureCount, and serves real
/LocalTrailers + /SpecialFeatures items playable through PlaybackInfo.

Requires silo-plugin-sdk v0.9.0 (VideoRecord) before go.mod can bump;
builds locally via go.work against the SDK feat/metadata-videos branch.

Part of trailers/extras capability work.

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

* feat(web): trailers and extras sections, library trailer-kinds setting

TrailersSection (YouTube thumbnails + youtube-nocookie modal) and
ExtrasSection (plays extras through the standard watch controller) on movie
and series detail pages; admin library form gains a trailer-kinds
allow-list synced with the server default (all provider kinds), now also
honored on library create.

Part of trailers/extras capability work.

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

* fix(scanner): scan extra_id in scanMediaFiles; review cleanups

scanMediaFiles (the plural row scanner behind GetByContentID/GetByFolder/
GetByExtraID and 20+ other queries) was missing the scan destination for
the new extra_id column, which would have failed every media-file read at
runtime with a column/destination count mismatch.

Also: extend the batch equivalence test to seed item_videos/media_extras so
the new videos/extras prefetch wiring is actually proven; drop the one-off
pgxRows interface for the repo-wide pgx.Rows convention; reuse formatClock
instead of a third duration formatter in ExtrasSection.

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

* chore(deps): bump silo-plugin-sdk to v0.9.0 for VideoRecord

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

* fix(matching): exclude extras files from match queues and bulk content linking

Dev verification caught extras media_files rows (content_id NULL by design)
being swept into the movie/series match queues and the root-claim bulk
relink: a '-featurette' suffix extra was matched onto its parent as a
version, and a Trailers/ file minted a spurious local skeleton item that
shadowed the extra's watch target. Add 'extra_id IS NULL' to the queue
eligibility conditions, root/group claim relinks, observed-root content
assignment, and the admin unmatched-files listing.

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

* fix(playback): authorize local extras files through their parent item

Dev verification: playback/start (and the shared MediaFileAuthorizer used
by markers/subtitles/ebook reader) resolved file ownership only via
episode_id/content_id, so extras files (extra_id only) 404ed. Add an
ExtraLookup tier that resolves media_extras and gates on the parent item's
access, mirroring the episode->series pattern.

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

* fix(catalog): resolve local extras through GetItemDetail for compat playback

jellycompat PlaybackInfo (and any per-item consumer resolving arbitrary
content ids) goes through GetItemDetail, which lacked the extras tier that
GetWatchDetail has — so Jellyfin clients got zero MediaSources for extras.
Add buildExtraItemDetail (minimal detail + ordinary playback surface,
parent-gated access) as the fourth resolution tier, and map the extra type
to Jellyfin's Video kind.

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

* fix(web): allow youtube-nocookie embeds in CSP; trailer modal a11y

The frontend CSP's frame-src blocked the trailer modal's
youtube-nocookie.com iframe (found on dev verification). Also add the
missing sr-only DialogDescription and drop the redundant allowFullScreen
attribute.

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

* fix: address PR review findings for trailers/extras

- Extras watch/item detail no longer stamp SeriesID/SeriesTitle for
  movie-owned extras (players key episodic post-roll flows off series_id);
  series-owned extras keep them (Codex).
- processExtraFiles resolves the parent and upserts media_extras before
  the unchanged fast-path, and the fast-path now also compares mtime, so
  rematched parents / reclassified kinds / same-size replacements converge
  (Codex + CodeRabbit).
- media_files upsert clears content/episode linkage atomically when
  extra_id is set (ownership mutual exclusion in one statement); the
  now-redundant MarkFileAsExtra helper is removed (CodeRabbit).
- ScanFile's extras branch runs syncPresentLibraryState +
  reconcileLibraryMemberships so converting a primary file to an extra
  cleans stale library membership immediately (CodeRabbit).
- media_extras migration adds the media_files FK as NOT VALID + VALIDATE
  to avoid a full-scan exclusive lock on large tables (CodeRabbit).
- trailer_kinds input is trimmed/lowercased/deduped and unknown values are
  dropped instead of silently widening the allow-list to 'other'
  (CodeRabbit).
- Extras authorization branches match the episode branch's posture:
  unconfigured lookup is a config error, nil extra is a 404 (CodeRabbit).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:52:44 -04:00
42602b7896 feat(policy): access groups + embedded OPA policy engine with decision audit log (#282)
* docs(policy): add OPA policy engine design spec and implementation plan

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

* build(deps): add OPA v1.18.2 SDK for the policy engine

Pulls github.com/open-policy-agent/opa v1.18.2 (policy engine core for
the upcoming internal/policy subsystem) and the transitive upgrades go
mod tidy applied (otel 1.44, grpc 1.81.1, prometheus/common 0.67.5).
Full build verified.

Part of #272

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

* feat(policy): add OPA engine core, vendor scope policy, and parity suite

New internal/policy package (dead code — nothing wires into request paths
yet): prepared-query Engine with 25ms eval timeout and fail-closed decode,
typed PDP.ResolveViewerScope, go:embed vendor bundle, capabilities lockdown
for future admin-authored Rego, and vendor scope.rego reproducing
access.Resolver.Resolve (library intersection, disabled-library handling,
quality/rating ceilings) with a narrowing-only silo_custom.scope.override
extension hook.

Parity proven by 1368 dual-execution subtests against the real
access.Resolver, including the nil-vs-empty AllowedLibraryIDs battery and
quality/rating variation; rank tables are test-pinned to internal/access.
Rego unit tests run via opa/v1/tester inside go test. Bench:
~106µs/op per scope decision incl. input marshaling.

Also restores the OPA requirement to go.mod (the earlier deps commit ran
go mod tidy before any import existed, so tidy dropped it).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed,
corrected (quality.allowed raw-file-rank divergence), and verified here.

Part of #272

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

* feat(policy): add policy document store, foundation schema, and compile-check

policy_foundation migration: policy_documents (one enabled doc per domain
via partial unique index — two enabled docs would define override twice
and conflict at eval), immutable policy_document_versions, single-row
policy_generation counter, and the partitioned policy_decisions log table
(daily range partitions, no FK, denial partial index).

PolicyStore: transactional version numbering (FOR UPDATE), activation
that verifies compiled_ok and bumps the generation in the same tx,
enable/disable with typed ErrDomainAlreadyEnabled, and a delete guard for
documents with an active version. CompileCheck sandboxes admin Rego:
locked capabilities (no http.send/net.*/opa.runtime), enforced
silo_custom.<domain> package path, vendor+stub layering, 2s budget,
structured row/col errors. Engine gains NewEngineWithCustom /
NewEngineFromStore with WARN-and-skip for invalid custom rows.

DB-backed tests verified against a migrated Postgres (concurrent version
numbering, atomic generation bumps, activation guards).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (domain constants extracted).

Part of #272

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

* feat(policy): add policy System lifecycle with hot reload and cross-node invalidation

policy.System owns one long-lived Engine and reloads it in place when
policy documents change: EventPolicyChanged on the existing ChannelAdmin
bus (new cache event constant) plus a 60s generation-poll fallback for
Redis-less deployments, with a generation-consistent snapshot read.
Vendor compile failure is startup-fatal; store/custom failures degrade
to vendor-only and the poll loop heals them; runtime reload failures
keep the last known-good engine. NotifyChanged gives the future admin
handlers synchronous local reload + cross-node publish.

Wiring: constructed in integrated/api modes only, PolicySystem field on
api.Dependencies (unused by routes yet), policy.eval_timeout_ms setting
(hot-reloaded via configWatcher.OnChange; default 25ms). Verified by a
full server boot smoke and DB-backed convergence tests (event + poll
paths, degraded boot, last-known-good).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here.

Part of #272

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

* feat(policy): add async decision logging with sampling, retention, and query repo

DecisionLogger batch-inserts each node's policy decisions straight to
the partitioned policy_decisions table via a non-blocking buffered
channel (drop-and-count on overflow — logging never adds latency to or
fails a decision). Scope decisions sample 1-in-N (default 50, setting
policy.decision_log_scope_sample_rate); denials and eval errors always
log; input/result JSON samples only at policy.decision_log_verbosity=
verbose. Cursor-paginated DecisionRepository backs the upcoming admin
log viewer. Retention via partman (daily partitions) and a
PolicyDecisionLogCleanupTask honoring policy.decision_log_retention_days
(default 14). PDP emits entries per evaluation; the System owns the
logger lifecycle and settings hot-reload.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (removed an unused, unsynchronized PDP setter).

Part of #272

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

* feat(api): add admin policy management API and capability endpoint

/api/v1/policy/capability (authenticated feature detection) plus the
acting-admin /api/v1/admin/policy surface: vendor Rego viewer, document
CRUD with the one-enabled-per-domain conflict mapped to 409, immutable
version creation (compile-checked; failed versions persist as audit
history with structured row/col errors and can never activate),
activate/rollback with synchronous reload + cross-node invalidation via
System.NotifyChanged, stateless validate, throwaway-bundle simulate
(never touches the live engine, never logs decisions), and
cursor-paginated decision-log queries. Routes mount only when the
policy system is wired, keeping proxy/transcode modes untouched.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (seeded the FK'd test user; replaced an unchecked
fmt.Sscanf with strconv).

Part of #272

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

* feat(web): add /admin/policy workspace with Rego editor, simulate, and decision log

New Policy admin page (System nav group): documents list with
one-enabled-per-domain conflict handling, CodeMirror 6 Rego editor
(hand-rolled StreamLanguage mode) with server compile issues rendered as
inline lint diagnostics, explicit Save-version vs Activate flow with
confirm, read-only vendor module viewer, simulate panel with seeded
example inputs, version history with rollback, and a cursor-paginated
decision-log browser. Capability-gated via /policy/capability. Adds the
three decision-log settings to Log Retention. First code-editor
dependency in web/ (@uiw/react-codemirror + @codemirror/*), decided in
the design spec.

Implementation drafted by Codex (GPT-5.5) via codex exec; verified here
(lint, format:check, tsc --noEmit, vitest policy suites).

Part of #272

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

* feat(policy): make OPA authoritative for viewer scope resolution

policy.ViewerResolver implements the ViewerResolver interface backed by
PDP.ResolveViewerScope and replaces access.Resolver at all five
construction sites: router viewer middleware, notifications scopes, the
reconciler, jellycompat's scope filter, and the ABS resolver (which now
accepts a pre-built resolver, preserving its PIN-at-login semantics).
PIN/profile-token verification and disabled-library loading are
extracted into shared exported helpers used by both implementations, so
the legacy resolver stays compiled as the parity reference with
identical behavior. The adapter lives in internal/policy (which already
depends on internal/access transitively) — direct typed PDP calls, no
new import cycle. Sites without a policy system (proxy modes, bare test
routers) keep the legacy resolver until the cleanup phase.

Verified: full test suite green (jellycompat TestBeginWebOperation* and
one playback GPU test are pre-existing failures, confirmed identical on
main), 1368-case parity suite, dedicated ViewerResolver parity/PIN/
nil-vs-empty/fail-closed tests, and a full server boot smoke.

Implementation drafted by Codex (GPT-5.5) via codex exec; a first-pass
reflection-based adapter was rejected and reworked into the typed
in-policy adapter; reviewed line-by-line and verified here.

Part of #272

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

* feat(policy): make OPA authoritative for acting-admin and permission gates

vendor/permission.rego reproduces the acting-admin rule (admin role +
primary-profile-or-none), HasEffectivePermission semantics for
marker_edit, and the metadata-curation rule including the subtle
admin-past-refused-bypass case that requires the explicitly ASSIGNED
permission. Policy-backed middleware in policy_gates.go keeps all Go-side
lookups (declared-profile primary check, item->library resolution, the
404-on-unknown-item path) and preserves the legacy status/body taxonomy
exactly — proven by dual-execution middleware tests that run every
scenario through both implementations and assert byte-equal responses.
Permission decisions always log (allowed flag populated); simulate and
the capability endpoint gain the permission domain automatically via the
domain registry. Router swaps behind single constructor choice points
with the legacy gates retained for policy-less wiring.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here.

Part of #272

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

* feat(policy): make OPA authoritative for download and playback admission decisions

vendor/action.rego decides download eligibility (downloads enabled +
user allowed), download-transcode eligibility (transcode enabled + user
allowed + artifacts available), and playback admission (stream/transcode
counts vs limits, zero = unlimited), with a tightening-only
silo_custom.action override that can also clamp a quality ceiling (never
widen — merged via quality.min). Go keeps everything stateful: config
loading, preset-ladder enumeration, and live session counting.

Downloads consult an optional ActionDecider (nil = legacy logic) mapped
back to the existing sentinel errors and capability response. Playback
gains a minimal AdmissionDecider hook at the exact point of the legacy
limit comparison: counts snapshot under the session mutex, PDP evaluated
OUTSIDE the lock, then revalidated under lock before insert (retry on
count drift) — no admission ever decided on stale counts and no eval
under the mutex. Deny reasons map to the legacy ErrTooManyStreams /
ErrTooManyTranscodes sentinels, pinned by tests.

Parity: combination tables driven against the real PresetsFor /
ensureTranscodeAllowed / SessionLimits math; full suite green (known
pre-existing jellycompat flakes only).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed
(locking design verified line-by-line) and verified here.

Part of #272

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

* fix(web): satisfy tsc -b strict return typing in the Rego stream tokenizer

The production build (tsc -b) rejects assigning CodeMirror's
string | void next() result to string | undefined; tsc --noEmit did not
catch it. Restructured the string-literal loop.

Part of #272

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

* fix(policy): clearer error when a decision is undefined for partial input

Vendor policies index required input fields directly, so a hand-written
simulate payload missing fields yields an undefined decision. Surface
that as 'decision X is undefined for this input (missing required input
fields?)' instead of 'empty result' — found while exercising the
simulate API against a live server.

Part of #272

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

* chore(web): set changeOrigin automatically when the API proxy target is remote

Remote dev backends sit behind vhost-routing proxies that reject a
localhost Host header; local targets keep the existing pass-through
behavior. Enables pointing the Vite dev server at a hosted backend via
VITE_API_PROXY_TARGET in web/.env.local.

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

* refactor(web): redesign the policy workspace around the decision pipeline

The first-pass UI was structurally generic: a five-column document table
squeezed beside the editor, three equal-weight action buttons with
hidden preconditions, raw version IDs, and jargon copy — nothing taught
the model. The page now teaches it:

- A pipeline strip states the mental model up front: Silo decides the
  baseline -> your overrides narrow it -> every decision is logged. Tabs
  renamed to Overrides / Baseline / Decision Log (ids stay stable for
  bookmarked URLs).
- The document table becomes one card per domain (Library visibility /
  Admin & permissions / Downloads & playback) with plain-language
  descriptions, example rules, status pills (Live vN / Draft / Disabled),
  inline creation, and the enable kill-switch in place.
- Selecting an override drills into a full-width editor with a visible
  lifecycle rail (Draft -> Validated -> Saved -> Live) and one contextual
  primary action per step; the unedited live source shows no actions
  until edited. Version comments appear only at the save step.
- Simulate is reframed as 'Test before going live' with a human verdict
  chip (Allowed / Denied — reason / ceiling summary) above the raw JSON;
  internal generation counters no longer surface.
- History uses 'Make live' with plain go-live copy; authors read
  'User N'; the baseline tab explains that upgrades never touch
  overrides.

Hand-written redesign (no Codex); verified via vitest, tsc, eslint,
prettier, and a production build.

Part of #272

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

* refactor(web): present the policy baseline as readable rules, not raw Rego

The Baseline tab dumped five Rego modules into read-only editors. It now
leads with what the rules actually do: one card per domain with
plain-language statements of the shipped behavior and a note on what an
override may change, plus content-rating and playback-quality tier
ladders parsed live from the lib module sources (so the tiers shown are
the ones the server enforces, not a hardcoded copy). The Rego source
stays one click away behind a per-module accordion and remains the
stated source of truth; unrecognized modules fall back to source-only.

Part of #272

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

* docs(policy): add access-groups design addendum

Groups with permission toggles become the everyday admin surface; the
Rego editor is demoted behind policy.editor_enabled (default off).
Restriction-only composition: group grants are an upper bound, per-user
settings tighten further — same rule as the existing account/profile
merge, one layer up.

Part of #272

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

* feat(access): add access groups — group defaults with restriction-only composition

New access_groups table + users.access_group_id (one group per user, NULL
= today's behavior). Group grants are an upper bound composed with the
user's own settings by strictest-wins rules — library intersection,
MinQuality, AND'd booleans, strictest positive stream/transcode limits,
permission-mask intersection, and a requests toggle gating CreateRequest.
The merge happens in Go (access.ApplyGroupPolicy /
EffectivePolicyForUser) before policy inputs are built, so vendor Rego,
the parity suites, and the decision log are untouched; every enforcement
surface (viewer scope in both resolvers, permission gates, downloads,
playback admission, requests) consumes the effective policy and fails
closed on provider errors. Changing a group's quality ceiling bumps its
members' access_policy_revision, mirroring the per-user rule.

Additive admin API: /admin/access-groups CRUD with member counts;
PUT /admin/users/{id} + user DTOs gain access_group_id.

Also demotes the Rego editor: policy.editor_enabled (default off,
hot-reloaded) drives the capability endpoint's editor_available and
403-gates editor endpoints while the engine and decision logging keep
running.

Design: docs/superpowers/specs/2026-07-02-access-groups-design.md.
Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed
(composition core + fail-closed call-site audit) and verified here.
DB-backed group-store tests pending local Postgres recovery.

Part of #272

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

* feat(web): add Access Groups admin page and gate the policy editor

New /admin/access-groups: a card grid summarizing each group (member
count + key restrictions), drilling into an editor that reuses the same
LibraryAccessSelector and quality presets as the user editor, with
toggles for downloads/transcoded-downloads/requests, concurrent-stream
and transcode limits, and a permissions mask (all-assignable by default,
narrowable to specific permissions). Delete warns how many members fall
back to the built-in defaults. Copy states the composition rule up front:
a group grants the most a member can do; their own restrictions still
apply on top.

The user editor gains a Group picker and read-only row; the Policy nav
entry is now hidden unless the capability reports the editor enabled.
Plumbing (types, hooks, user-editor picker, nav gating) drafted by Codex
(GPT-5.5); the Groups page hand-built. Verified: 25 tests across the
touched suites, tsc, eslint, prettier, and a production build.

Part of #272

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

* feat(access): seed a Default Group and auto-assign newly created users

Adds access_groups.is_default with a partial unique index (one default
at most — the profiles is_primary pattern) and seeds a permissive
'Default Group' whose ceiling is a no-op, so assignment never changes
anyone's effective access until an admin edits it. The seed is guarded
against pre-existing defaults and name collisions; the Down migration
only removes the row if it is still untouched.

Assignment happens at the single INSERT INTO users choke point
(UserRepository.Create): when no explicit group is given, access_group_id
is filled by a scalar subquery on the default flag — NULL when no default
exists. Every creation path (setup, signup, invites, OAuth, admin create)
is covered by construction. Setting a new default via the API atomically
clears the previous one in the same transaction.

Deleting or unsetting the default is legal: new users then start with no
group, which is pre-feature behavior.

Implementation drafted by Codex (GPT-5.5); migration guards and the
choke-point subquery reviewed line-by-line here.

Part of #272

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

* feat(web): surface the default access group

Cards show a Default badge; the group editor gains a 'Default for new
users' toggle (with copy noting existing users are never moved); the
delete dialog warns when removing the default that new accounts will
start with no group.

Part of #272

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

* feat(access): ship the Default Group with house-rule ceilings

Seed values per product decision: 5 concurrent streams, 5 transcodes,
transcoded downloads off, and a permission mask of marker_edit only
(metadata curation excluded). Plain downloads and requests stay on. The
Down guard matches the new values so it still only removes an untouched
seed row. Only newly created users are affected; existing users are
never assigned.

Part of #272

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

* feat(access): retire per-user defaults — the Default Group is the sole default policy

Removes both legacy 'user defaults' mechanisms now that the seeded
Default Group owns new-user policy:

- users.max_streams / max_transcodes column defaults drop from 6/2 to 0
  (= unrestricted at the user layer), so group ceilings apply to new
  signups/invites/OAuth users instead of fighting stale per-user
  numbers. Existing rows keep their stored values — nobody is silently
  uncapped on upgrade.
- The dead defaults.max_playback_quality / defaults.max_profiles
  settings validation goes away with its only writer (the User Defaults
  dialog, removed on the web side).

Part of #272

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

* feat(web): replace the User Defaults dialog with group-governed creation

The Users page's 'User Defaults' dialog (defaults.* server settings)
duplicated what access groups now do properly, and its values were only
ever form prefill — no backend path applied them. The button now links
to Access Groups, and the create-user form seeds unrestricted user-layer
values (0 streams/transcodes, any quality, downloads allowed) so the
member's group governs; per-user fields remain for tightening individual
users.

Part of #272

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

* feat(access): migrate existing non-admin users into the Default Group

Existing users join the seeded Default Group on upgrade so one policy
source governs the whole instance. Their per-user limits still holding
the retired 6/2 column defaults are normalized to 0 in the same
statement so the group's ceilings actually apply; deliberately
customized values are preserved. Admin accounts stay ungrouped —
scope/action decisions are role-blind, so grouping an admin would cap
the server owner on upgrade.

Part of #272

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

* fix(access): keep admins out of the Default Group and treat group moves as policy changes

New-user creation now mirrors the migration's admin exclusion: the
default access group is only auto-assigned to non-admin roles, so a
fresh server owner no longer inherits the starter group's transcode
denial and stream caps.

Changing a user's access group now bumps access_policy_revision (the
group carries permissions, quality, and limits, exactly like the
per-user fields that already bump it) and triggers admin session
revocation when the group actually changes.

Part of #272

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

* fix(policy): enforce marker_edit through the PDP on marker write routes

The Rego permission policy owned marker_edit but no Go caller ever
consulted it: PUT/DELETE /markers went through a handler-local check
that short-circuited admins and read only the user's own permissions,
so group permission masks and custom policy overrides were ignored.

Marker writes are now gated by router middleware like the other
permission surfaces: a PDP-backed RequireMarkerEdit that evaluates the
group-merged effective permissions (plus the legacy variant for
proxy/test wiring without a policy system). The handler-local check and
its user loader are gone.

Part of #272

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

* fix(downloads): assert device/quality policy facts and honor the quality ceiling

The download_transcode action check hard-coded an empty device ID and
never asserted the requested quality, and no caller consumed
ActionDecision.QualityCeiling — custom download policies keyed on those
inputs were silently ineffective.

Resolve now threads the request's device ID and requested quality into
the action input, and a returned quality ceiling downscales the
prepared transcode target (the ceiling applies to what is served,
matching the serve-time rule in serveDownloadBytes). FileQuality and
the content-rating pair stay intentionally empty for downloads —
documented on downloadActionInput: those ceilings are enforced against
the served artifact by the scope-derived access filter, and asserting
the source's quality would wrongly deny capped transcodes.

Part of #272

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

* test(access): align the default-group seed assertions with the migration

The DB test still asserted the earlier no-op seed (transcode allowed,
unlimited streams/transcodes, null permissions); the shipped migration
seeds transcode denied, 5/5 limits, and marker_edit-only permissions,
so the test failed on any database with the migration applied.

Part of #272

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

* fix(policy): lock the Rego sandbox by builtin purity and bound compile work

Exclude every nondeterministic builtin from the admin sandbox instead of
denylisting names, so OPA upgrades cannot silently expose impure builtins
while pure helpers like net.cidr_contains stay usable. Apply the same
capabilities to the runtime engine, cap concurrent compile checks, and
reject oversized sources before they reach the uncancelable compiler.

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

* fix(policy): require literal booleans in vendor override and input checks

Bare object.get truthiness treated any non-false value as satisfied, so a
malformed override 'allowed' value could fail to tighten a base grant and
hand-crafted simulate input could flip flag predicates. Compare against
literal true so anything else denies.

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

* fix(policy): surface decision log cleanup failures to the task manager

CleanupDecisionLogsOnce now returns the first error alongside the deleted
count so a broken partition manager or DB outage marks the scheduled task
failed instead of reporting 100% success while policy_decisions grows.

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

* fix(playback): log admission decider errors before failing closed

A policy-evaluation failure was silently mapped to the too-many-streams
denial, making an engine outage indistinguishable from a real limit hit.

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

* fix(access): nil-guard the downloads user and restore the ABS legacy resolver

effectiveDownloadUser dereferenced policy state before its nil-user check,
and the ABS handler lost viewer-scoped filtering entirely when the policy
system was unavailable because no legacy access.NewResolver fallback was
wired like the other resolver paths.

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

* fix(web): address admin policy review feedback

- invalidate the version query by version_number, the key usePolicyVersion
  actually caches under
- keep the goPrevious cursor-stack updater pure (Strict Mode double-invoke)
- make version history rows keyboard-selectable like the document list
- clamp download_transcode_allowed when downloads are disabled so groups
  cannot save a contradictory record

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

* fix(api): cap policy endpoint request bodies at 1 MiB

The policy write endpoints (create document/version, set enabled,
validate, simulate) decoded JSON bodies without a size limit, so an
oversized payload buffered fully in memory before CompileCheck's
256 KiB source cap could reject it. Route all five through a shared
decodePolicyRequest helper that wraps the body in http.MaxBytesReader
and returns 413 with the repo's standard too_large error shape.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(access): forbid deleting or demoting the default access group

Deleting the default group (or unsetting its is_default flag) left the
server with no default: new non-admin users were then created ungrouped
with max_streams/max_transcodes of 0 — unlimited — because the legacy
per-user column defaults were retired in favor of the group's ceilings.

The store now rejects both operations with ErrDefaultGroupRequired
(mapped to 409); promoting another group remains the supported way to
move the default, and atomically clears the previous one. The admin UI
disables the delete button and the default toggle on the default group
and explains the promote-another-group flow.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(web): keep unsaved policy drafts when a newer version activates elsewhere

The editor state was keyed on the active version's id/sha, so a
background refetch after another admin (or another tab) activated a
version remounted the editor and silently discarded the dirty draft.

PolicyEditorPanel now pins the seed it is editing against and only
adopts an incoming seed when nothing can be lost: the editor is clean,
the draft already equals the incoming source (the same-admin activate
flow), or the selection moved to a different document. Otherwise the
pinned editor stays mounted and an inline notice offers an explicit
"Load live version" action.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(policy): fail reloads on invalid custom sources and surface degraded/apply state

A stored custom source that stops compiling used to be silently skipped on
reload: the bundle widened to vendor-only for that domain while the generation
reported fully applied. Reload is now strict — a bad enabled source fails the
reload and the last known-good engine keeps serving. Boot keeps its vendor
fallback for availability, but skips are recorded on the engine and exposed
(with store-outage reasons) through System.DegradedState and additive
degraded fields on GET /policy/capability. Activate/SetEnabled re-run
CompileCheck instead of trusting the stored compiled_ok flag.

Mutation endpoints also no longer conflate persistence with live apply:
activation/enable responses carry additive applied/failed_step/
loaded_generation fields and return 202 when the store change persisted but
the local reload failed.

Addresses review findings C1, C2, and the degraded-signal gap (6.1).

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

* fix(policy): type deny reasons across the contract and enforce profile_verified

Deny handling used to branch on exact free-text reason strings in three Go
consumers, and playback reported ANY unrecognized reason — including custom
override free text and engine failures — as a stream-limit error. Decisions
now carry a stable reason_code (custom overrides always get custom_denial);
downloads, the metadata-curation gate, and playback admission switch on codes,
with a new ErrPlaybackNotAllowed -> 403 playback_not_allowed mapping for
non-limit denials. Rego tests pin every vendor code.

The scope contract's tighten-only profile_verified output was also emitted but
never consumed; a policy revocation now surfaces as ErrProfileUnverified (403
profile_unverified) instead of silently proceeding.

Addresses review findings 6.2 and C4.

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

* fix(catalog): close the dual-library disabled-scope bypass in direct item authorization

EnsureAccessible, EnsureAccessibleIDs, and FilterAccessibleContentIDs gated
library access with allow/deny predicates over a single joined
media_item_libraries row, so an item linked to BOTH a passing library and a
disabled one satisfied the disabled check via the passing row — a direct-ID
bypass of disabled-library scope on the detail, media-file, playback, and
download paths. All library access predicates now share one helper
(libraryAccessConditions) emitting independent EXISTS / NOT EXISTS subqueries,
the semantics GetByIDsWithAccess already used, including the orphan-item
membership guard for disabled-only scopes. SQL-shape tests pin every builder
and a DB-gated regression test covers the dual-library item end to end.

Addresses review finding C3 (plus the same shape in
buildFilterAccessibleContentIDsSQL, which the review did not flag).

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

* fix(downloads): serialize quota check and row creation under a per-user advisory lock

The concurrent-download quota was check-then-insert with nothing serializing
the pair: parallel creates could all observe free quota before any row
existed, bypassing the cap and stacking artifact encode jobs. All four
check->insert spans (ephemeral original, artifact-backed, series batch,
managed batch) now run inside Repository.WithUserQuotaLock — a
pg_advisory_xact_lock keyed by user, so the serialization holds across nodes.
The artifact path keeps the limiter-before-Ensure ordering (a rejected request
must not leave an encode job behind) by holding the lock across Ensure.
Managed-entry replacement stays quota-exempt and lock-free. A DB-gated
barrier test races 8 creates against a cap of 1.

Addresses review finding C5.

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

* fix(downloads): assert served quality at create time for original and remux downloads

Direct-original and remux downloads serve the source resolution unchanged, but
create-time policy checks left file_quality empty — an over-ceiling source
registered a row serveDownloadBytes could never satisfy. Resolve now runs a
final download action check with FileQuality populated on those two paths
(capped transcodes keep the ceiling-on-artifact behavior), a custom override
ceiling below the served resolution denies, and quality_ceiling_exceeded maps
to ErrQualityUnavailable. The ActionInput contract now documents exactly when
file_quality and the rating facts are supplied so custom policy authors are
not misled.

Addresses review finding C6.

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

* fix(policy): guard activation against slow overrides and make eval timeouts observable

A custom scope override that exceeds the 25ms eval budget compiled fine,
activated fine, and then converted to 500s on every authenticated request —
server-wide lockout authored in the admin editor. Activation and enable now
run GuardEvalCost: the candidate source is evaluated on a throwaway engine
against a canned representative input under the live budget, and a source
that cannot complete is rejected 422 with ErrPolicySlowEval before it goes
live. Runtime timeouts keep failing closed but now carry a distinct
ErrPolicyEvalTimeout sentinel, an Error log, and a per-engine counter exposed
as eval_timeouts on GET /policy/capability so intermittent near-budget
policies are attributable.

Addresses review finding C7.

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

* style: gofmt remediation files

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:38:19 -04:00
2e0e145721 feat(settings): per-user date and time display format settings (#304)
* feat(settings): per-user date and time display format settings

Add ui.date_format (auto, DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD) and
ui.time_format (auto, 12h, 24h) as validated user-scoped settings, a
shared preference-aware formatter module (web/src/lib/datetime.ts)
synced via DateTimeFormatProvider, a Date & time section in Appearance
settings, and convert all absolute date/time display call sites to the
shared formatters.

Closes #303

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

* fix(settings): make loaded API settings authoritative for date/time formats

Address adversarial review: once the authenticated settings request
resolves, a missing ui.date_format/ui.time_format key means "auto"
instead of falling back to device-wide localStorage (which could carry
another user's preference), and failed saves roll back through the
query cache. Layout and AdminLayout subscribe to the format store so
all routed pages re-render live when the preference changes.

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

* fix(settings): reliable re-render and rollback for date/time format changes

ReactiveAppRoutes re-renders the routed page tree when the format
preference changes (a Layout-level subscription cannot re-render stable
children elements); memoized AdminLogs rows and the out-of-route
PlayingNextScreen subscribe directly. useSetSetting now rolls back only
the mutated key on error and invalidates the settings list on settle so
overlapping saves cannot resurrect stale values.

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

* fix(settings): guard same-key rollback against newer optimistic saves

Roll back a failed setting save only while its optimistic value is
still current in the cache, invalidate the detail query on settle, and
add a regression test for overlapping same-key mutations.

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

* fix(settings): owner-bind the datetime format warm start, pad 24h hours

Address PR review: the localStorage warm start is now tagged with the
user id that mirrored it and is ignored for a different authenticated
user, so a failed settings request can no longer leak another account's
format on a shared browser. The 24h branch of formatTime defaults to
2-digit hours ("09:04") since h23 alone does not guarantee padding in
every locale.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:45:06 -04:00
QuickandClaude Fable 5 9ead29bfcc fix(historyimport): make Plex watchlist import actually work
The browser Plex OAuth flow only sent the PMS server access token, which
the discover API rejects (401), so the watchlist step always failed with
a buried warning. The web client now forwards the plex.tv account token
via a new additive plex_account_token field.

The discover watchlist listing also ignores includeGuids, so items
arrived without external ids and could only exact-title/year match.
FetchWatchlist now resolves ids per item from the discover metadata
endpoint, decoding both Metadata- and Video-keyed containers, and
degrades to a title/year fallback warning instead of dropping items.

Part of #245

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:11:12 -04:00
c0f209a936 feat(catalog): Latest Episodes sort — order series by newest episode file (#283)
* feat(catalog): Latest Episodes sort — order series by newest episode file

Adds a latest_episode_added sort so users can see which shows received
new episodes. Today's recently-added surfaces reflect when the SERIES
was first added: linking a new episode file never bumps the series'
media_item_libraries.first_seen_at (ON CONFLICT DO NOTHING), so a
long-running show with a fresh episode sorts as stale (#202).

- New denorm media_items.latest_episode_added_at (migration + backfill
  + partial series index), mirroring the last_air_date_at precedent.
  Source of truth is episode_libraries.first_seen_at; the three insert
  paths (UpdateEpisodeLink, BulkLinkEpisodesBySeries, scanner folder
  restore) bump the parent series atomically in the same statement,
  monotonically via GREATEST, and only for genuinely new links.
- Sort registered in both frameworks: querySortDefs (sections + smart
  collections + /v1/catalog pick it up automatically via
  QuerySortFieldSet) and the browse buildOrderByPlan path.
- Jellyfin compat: SortBy=DateLastContentAdded now maps to the new sort
  instead of silently collapsing to series creation date — Jellyfin
  clients already send this for the TV "Latest" shelf, so they get the
  correct behavior with no client changes. DatePlayed keeps its old
  created_at mapping instead of piggybacking.
- Web sort picker gains "Latest Episode Added" (series scope).

Additive-only per v1 API rules: new sort value, no field/status changes.

Part of #202
Fixes #202

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

* fix(web): include latest_episode_added in the api QuerySort field union

The picker-side QuerySortField gained the value but the api-layer
QuerySort union did not, breaking the production tsc build.

Part of #202

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

* fix(scanner): recompute latest_episode_added_at when episode memberships are removed

The denorm was only ever bumped upward (GREATEST) at insert time, but
UpdateEpisodeLink also deletes the old episode's library membership on
re-link, and reconciliation/path-prefix clears remove memberships too —
leaving a stale timestamp that kept the series sorting as recently
updated. All removal paths now run in a transaction and finish with a
shared full MAX() recompute (catalog.RecomputeSeriesLatestEpisodeAdded)
that also resets to NULL when no memberships remain, mirroring the
last_air_date_at maintenance pattern.

Sequential statements are load-bearing here: data-modifying CTEs are
invisible to reads in the same statement, which also silently no-op'd
the old path-prefix membership delete.

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

* fix(jellycompat): keep DateLastContentAdded scoped to series-only requests

mapSortBy runs for every /Items browse, so the latest_episode_added
mapping leaked into movie and untyped requests where the column is
always NULL, destroying the previous created_at ordering. The sort now
falls back to created_at unless IncludeItemTypes is exactly Series.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-04 23:43:19 -04:00
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

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

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

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

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00
cf0db385f3 Add Apple push notifications support (#255)
* Add push notifications support

* fix(notifications): address push notification review findings

- Gate the capability endpoint's apple_push availability on the admin
  delivery toggle, matching web push: Available now means setup will
  actually deliver.
- Reject direct admin writes to push_relay_deployment_id/api_key; the
  relay issues them as a pair during registration and a lone write
  desyncs them (and poisons the next rotation request).
- Purge a device's registrations under other profiles when it
  re-registers, so a profile switch on a shared device stops the old
  profile's pushes (attempts cascade); adds a DB-backed test.
- Extract the shared channelDispatcher core + retry sweep and rebuild
  the webhook/web push/Apple push dispatchers on it instead of keeping
  three copies of the worker-pool/retry loop.
- Deduplicate relay URL validation (admin setting + register flow) and
  the push outbox attempt-building loops behind shared helpers.
- Cap free-text decline reasons in notification display bodies.
- Fix TestHandleApplePushDisplayDB expectations to match the shared
  display copy (test previously failed under SILO_TEST_DATABASE_URL).

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

* fix(notifications): route push relay URL writes through registration only

Direct writes to notifications.push_relay_url via the admin settings
endpoint bypassed the relay registration flow, letting the stored URL
drift out of sync with the deployment id / API key pair the relay
minted for it. Reject the URL alongside the deployment id and API key
in the settings handler; POST /admin/notifications/push/relay/register
remains the only path that persists all three together.

The admin UI's Relay URL field now edits local draft state and is
applied by the Register/Rotate action instead of the settings save.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:25:16 -04:00
Quick 40329f616d perf(search): speed up catalog query results 2026-06-25 20:46:08 -04:00
Quick 6ca427096b Add catalog search provider support 2026-06-25 16:20:14 -04:00
87159b0a38 feat(collections): add profile-scoped display filters (#191)
* feat(collections): add profile-scoped display filters

* refactor(collections): dedup display-filter helpers per review

Address code-review feedback on the profile-scoped display filters
without changing behavior:

- Widen CompletedHistoryItemMap to accept ProgressCompletionStore and
  drop the duplicate completedHistoryItemMapForProgress copy.
- Extract the duplicated MDBList candidate retry loop into a generic
  collectionutil.FetchMDBListWithFallback helper, used by both the user
  and library collection syncers, and cover it with unit tests.
- Reuse validateOptionalLibraryIDs in HandleUpdateCollection instead of
  an inline positive-ID loop.
- Import the shared COLLECTION_{WATCH,MEDIA}_FILTER_OPTIONS in the
  template config form rather than redefining them locally.

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

* fix(collections): sanitize query_definition library_ids fallback

readSourceConfigLibraryIDs validated source_config.library_ids (finite,
positive, truncated, deduplicated) but returned the query_definition
fallback raw, so legacy rows could surface zero/negative/duplicate IDs
that the backend now rejects on save. Extract a shared sanitizer and
apply it to both paths.

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

* refactor(docs): This makes the agents annoying to work with

* Improve playback session handling

* Support collection source order in catalog filters

* fix(collections): address display filter review feedback

* refactor(catalog): remove duplicate collection query params

* Hide episode media scope for collection overlays

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:38 -04:00
c4cbcddeae feat(manga): manga library type — series grouping, reading loop, AniList/MangaDex metadata + status badge (#138)
* docs: design spec for manga library type (host sub-project)

Forks the ebooks library type into a 'manga' type: series detected from the
folder tree as a first-class type='manga' item, .cbz/.cbr chapters stay
readable ebook items linked via a new manga_chapters table, browse shows series
cards, enrichment targets the series item at content level 'manga'. Hands off to
a follow-on plugin spec for the manga metadata source.

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

* docs: implementation plan for manga library type (host)

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

* feat(scanner): manga filename index/volume parser

* feat(scanner): manga series-name-from-folder detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): align manga DB/scanner tasks to scanner pure-planner pattern (no test-DB)

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

* test(scanner): manga parser corpus regression

Add TestParseMangaIndexCorpus — 36 real-world scanlation filenames
covering bare chapter, decimal chapter, v/vol-prefix volume, and
c/ch-prefix chapter patterns; asserts <5% miss rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(db): manga_chapters link table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): manga_chapters repository + pure chapter-write mapping

Adds mangaChapterWrite (pure, unit-tested), upsertMangaChapter, and
listMangaChapters following the ebook/audiobook thin-SQL pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): recognize manga library type

Add isMangaLibraryType helper (unexported, matching the style of
isEbookLibraryType / isAudiobookLibraryType) with a corresponding
TestIsMangaLibraryType unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): manga library content level

Map library type "manga" to content level ["manga"] in
metadataContentLevelsForLibraryType so that seedDefaultChain seeds a
manga-level metadata provider chain when a manga library is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): route manga libraries to a manga scan path

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

* feat(scanner): group manga chapters under a manga series item

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

* fix(scanner): give manga series item a library membership so it browses

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

* feat(catalog): browse manga libraries as series

Accept "manga" as a valid media_scope so a manga library browses only its
type='manga' series items; the per-chapter type='ebook' items are naturally
excluded because MediaScopeItemTypes("manga") expands to {"manga"}. Add the
manga default library sections (scoped to media_scope='manga') so the library
feed shows series cards. Refresh the two media_scope validation error messages.

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

* feat(catalog): manga series detail lists chapters

For a type='manga' item, attach its chapters to the detail response via a new
MangaDetailExtension. fetchMangaChapters joins manga_chapters to media_items on
the chapter content ID, scopes to the series, and orders by chapter_index
(NULLS LAST) then sort_title — matching the scanner's chapter ordering.

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

* feat(web): manga detail types + library browse scoping

Add MangaChapter/MangaDetailExtension TS types mirroring the host
catalog structs, wire manga? onto ItemDetail, and admit "manga" as a
QueryDefinition.media_scope. Scope manga libraries to media_scope=manga
in browse (host expands it to type=manga series items) while reusing the
ebook sort universe via getLibrarySortRelevanceScope. Add isMangaLibraryType.

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

* feat(web): manga series detail with volume-grouped chapter list

Add MangaContent detail view: a DetailHero series header plus a chapter
list grouped by volume. groupMangaChapters (pure, unit-tested) buckets
chapters by their volume token, orders chapters within a group by
chapter_index (nulls last) and orders groups by their minimum index;
loose (volume-less) chapters collapse into a trailing "Chapters" group.
Each chapter links to the existing ebook reader by content_id alone
(file_id is optional — the reader resolves the file server-side), reusing
buildMediaPlayHref. Admit "manga" into ItemDetail.type and wire the
detail switch. Continue-reading is deferred (needs per-chapter progress
fan-out / a last-read timestamp not in the current payload).

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

* fix(web): handle manga in playable-type + collection filter-scope unions

Adding "manga" to the shared ItemDetail["type"] and
QueryDefinition["media_scope"] unions leaked into consumers with narrower
local types, breaking the production tsc build. Fixes:

- mediaNavigation: admit "manga" into PlayableMediaType. Manga series are
  not directly playable (you open the detail page and read a chapter,
  itself an ebook item), so buildMediaPlayHref falls through to the item
  href for them, like series/season.
- FilterRuleEditor: add "manga" to FilterRuleMediaScope and relabel
  "watched" -> "Read" for manga as well as ebook (manga is read).
- CollectionGuidedRulesEditor: add "manga" to GuidedFormState.mediaScope,
  a "Manga" media-type option, ebook-like "Read Status" labels, and map
  manga -> ebook sort-relevance scope (manga has no dedicated sort scope).
- CatalogFilterBar (cascading leak surfaced after the above): add a
  "Manga" scope option and map manga -> ebook sort-relevance scope in both
  scope handlers.

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

* feat(web): offer manga as a library type in the create dialog

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

* feat(scanner): strip scene-release junk from manga series names

Add cleanMangaSeriesName which repeatedly strips trailing parenthetical
groups (year, year-range, Digital, release-group tags) then trims any
dangling dash, so folder names like "404 Demons (Digital) (Oak)" resolve
to "404 Demons". Wire it into mangaSeriesFromPath so both the series
title and the mangaSeriesGroupKey identity key use the cleaned value.

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

* feat(web): flat volume/chapter manga list; nest only multi-chapter volumes

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

* fix(scanner): parse manga index after stripping series-name prefix

Numbers inside a series title (e.g. "404 Demons", "365 Days to the
Wedding") were wrongly grabbed as the chapter number because
parseMangaIndex matched the first bare number in the full filename.
mangaIndexForFile now strips the series-name prefix before delegating
to parseMangaIndex, so only the number that follows the title is used.
reconcileMangaFile in manga_scan.go is updated to call mangaIndexForFile
instead of parseMangaIndex directly.

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

* fix(scanner): stop missing-file reconcile from deleting manga series items

Manga series items are file-less virtual parents; the shared
ReconcileFolderMembership swept them every scan because they have no
media_file. Exclude type='manga' from file-presence membership reconciliation,
and add a manga-scan step that deletes only series with zero remaining chapters.

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

* fix(ebooks): exclude manga chapters from individual ebook enrichment

Manga chapters are type='ebook' parts of a series; the ebook enrichment sweep
was searching each one against book sources (Gutenberg/Anna's/etc.) and failing
in a pointless storm. Exclude items with a manga_chapters link; series-level
enrichment is handled separately.

* docs: design spec for manga metadata plugin + series enrichment (sub-project 2)

New silo-plugin-manga-metadata (AniList, high-confidence matching) + a host
MangaEnricher for type='manga' series; default-enabled metadata source for manga
libraries.

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

* docs: implementation plan for manga metadata plugin + series enrichment

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

* feat(db): manga_enrichment_state table

Mirrors ebook_enrichment_state: dedicated failure counter for the manga
enrichment sweep so it does not contend with media_items.refresh_failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(manga): series enricher (claims type='manga', resolves manga chain)

* feat(manga): sync_manga_metadata task + enricher wiring

* feat(catalog): expose manga chapter/volume counts in browse

Add manga_chapter_count and manga_volume_count to browse cards so the
frontend can render a Vols N / Ch N chip on manga series. The counts come
from two index-backed correlated subqueries over manga_chapters in the
browse SELECT (mangaCountColumns), scanned positionally before added_at and
nilled out for non-manga rows. Threaded through models.MediaItem and exposed
on the itemListResponse JSON card.

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

* fix(sections): scope manga home recent sections to type=manga series

A manga library mixes type='manga' series with type='ebook' chapters, so
the auto-generated home 'Recently Added/Released in <Library>' rows surfaced
the junk chapter filenames. Add GeneratedHomeLibraryRecentConfigScoped which
emits the modern QueryDefinition shape (library_ids + media_scope) so a manga
library's generated home rows filter to type='manga' only. Other library
types are unchanged.

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

* fix(catalog): exclude manga chapters from browse/section/search surfaces

Manga CHAPTER items (type='ebook' rows linked into a type='manga' series
via manga_chapters) were leaking into catalog browse, section resolution,
and search as standalone items showing junk filenames. They are internal
sub-units of the series and only the series should appear.

There is no single shared item-listing chokepoint: browse, the query/preview
executor, and search each build their own WHERE. Add a shared, index-backed
anti-join predicate (manga_chapters.chapter_content_id is the PK) via
mangaChapterExclusionWhere and wire it into all three builders. By-id fetch
paths that legitimately resolve chapters (ebook reader, continue-reading,
series detail chapter list) use separate queries and are unaffected.

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

* fix(scanner): use #NN as the manga volume for Vol.YYYY #NN releases

mangaVolYearIssue early-return was returning the year token (e.g. "Vol.2003")
as the volume label, which the frontend couldn't prettify to "Volume N".
Now returns "v<issue>" (e.g. "v04") so the existing frontend regex ^v?(\d+)$
renders it as "Volume 4" correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): manga count chip on posters

Add an optional manga_chapter_count / manga_volume_count to the browse
item type and render a top-right "Vols N" / "Ch N" chip on ItemCard,
strictly gated on type==='manga'. The label prefers "Vols" when the
volume count dominates, "Ch" otherwise; the chip is hidden when the
chapter count is missing or non-positive. No other card type renders it.

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

* fix(web): manga reader back returns to series (no loop)

The ebook reader's back action defaulted to the chapter's own item
detail (/item/<chapter>), whose back returned to the reader — an
infinite loop for manga chapters. The reader now honors an explicit
backTo search param when present, navigating there instead. Absent for
normal ebooks, so their back behavior is unchanged. Only manga chapter
rows pass backTo, keeping the fix manga-only.

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

* feat(web): manga chapter row actions (read/mark-read/download)

Each manga chapter/volume row now offers Read (the existing reader link,
now carrying a backTo to the series), Mark-read (the shared watched-state
mutation per chapter content_id), and Download (lazily fetches the
chapter's file versions on demand and opens the shared
DownloadVersionPicker, gated on user.download_allowed). The
volume-unit / loose-chapter / section structure from buildMangaList is
unchanged. Scoped to MangaContent only; EbookContent is untouched.

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

* fix(web): validate reader backTo param is a safe in-app relative path

Prevents open-redirect / javascript:-URI XSS from a crafted ?backTo= URL.

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

* feat(catalog): include per-chapter read state in manga detail

Manga chapters are ebook items, so a chapter is "read" when the viewer's
ebook_reader_progress row crosses the finished threshold. fetchMangaChapters
now LEFT JOINs that table scoped to the AccessFilter's user_id/profile_id and
exposes a per-chapter Read bool on MangaChapter, threaded through
buildMangaExtension. The detail payload previously carried no read state, so
the row toggle always started unread.

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

* feat(web): manga rows reflect read state on load

MangaChapter now carries an optional read flag from the detail payload, and
MangaRow seeds its mark-read toggle from chapter.read instead of always
starting unread. The optimistic toggle + shared watched mutation are
unchanged; only the initial value is seeded.

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

* fix(sections): exclude manga chapters from recently-added/released/random + other library-listing sections

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

* feat(sections): manga recently-added/released cards show the latest volume's cover

* fix(manga): keep enrichment honest about no-match vs enriched, batch 50->200

- sweep stats now separate enriched / no_match / failed: a stamped no-match
  was counted (and logged) as an enrichment, which masked a collapse of the
  real match rate during the backfill
- batch size 50 -> 200 (SILO_MANGA_ENRICH_BATCH overrides): with the plugin
  serving GetMetadata from its search cache an item costs one rate-limited
  AniList request, so a sweep still fits the 5-minute task interval

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

* fix(manga): size enrich batch to the 5-minute interval at AniList's real budget

140 items x ~2.1s/request fits the interval; an overlong sweep makes the task
manager drop the next trigger and the effective rate falls below the AniList
budget.

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

* fix(catalog): manga count chip data missing from library browse

manga_chapter_count/manga_volume_count were only added to BrowseRepository,
but /library/{id}?tab=library flows through previewQuerySource ->
QueryExecutor.PreviewPage, which selects qualifiedListItemColumns and scans
with scanItems - so manga cards never carried the counts and the Vols/Ch
poster chip stayed hidden.

Append mangaCountColumns to the preview-page SELECT and scan them via a new
scanItemsWithMangaCounts (nil for non-manga rows, mirroring scanBrowseItems).
Extract listItemScanDests so the three scan variants share one destination
list instead of duplicating the 48-column scan.

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

* feat(web): manga chip reads 'X Volumes · X Chapters', menu verbs say Read

- chip: show distinct-volume and loose-chapter counts side by side instead
  of the single 'Vols N'/'Ch N' heuristic; mangaCountColumns now counts
  DISTINCT volume tokens (rows sharing a volume are one volume) and only
  un-volumed rows as chapters
- watched-state labels: type='manga' fell through to the video default, so
  the card dot menu and detail page said 'Mark Watched' - manga now uses
  the ebook reading verbs (Mark Read / Mark Unread, 'Marked as read' toast)
- format MangaContent.test.tsx (pre-existing prettier miss)

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

* feat(manga): backdrop enrichment - banner hero art + backdrop-only backfill

- cache remote backdrops like posters (cacheRemoteImages generalizes the
  poster-only path; failures keep the provider URL, which still renders)
- claim arm for enriched items missing a backdrop: fetched by stored
  provider ID (search skipped - no rate spend, no re-match risk) and only
  the backdrop is written; stamping after the attempt keeps banner-less
  series from being re-claimed every sweep
- backfill = one-time SQL clearing last_refreshed for poster-set/
  backdrop-empty manga

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

* feat(manga): reading-loop UX - continue CTA, next chapter, series-aware cards, file details

Fixes the four high-priority findings from the manga UX review plus a
file-inspector request:

- H1: series hero gets a Continue / Start Reading / Read Again CTA
  targeting the first unread chapter (firstUnreadChapter over the ordered
  list), plus an overflow menu (View Details, admin Refresh Metadata)
- H2: the reader resolves its owning manga series (chapter detail now
  carries series_id/series_title) and offers next-chapter navigation: a
  header next button and an end-of-book floating CTA at >=99.5% progress;
  back defaults to the series even without a backTo param
- H3: chapter rows show a persistent read check + muted title, and the
  mark-read mutation carries series_id so the series detail cache
  invalidates (read states no longer revert on revisit)
- H4: continue-reading cards for manga chapters present the series:
  sections payload resolves chapter->series linkage, the card heading/image
  link to the series, and meta lines launch the reader
- View Details: manga series menus (card dot menu + detail overflow) open
  a file inspector showing folder paths and per-chapter file names/sizes
  via GET /catalog/items/{id}/manga-files; paths are stripped for viewers
  without file-path visibility (item-versions policy)

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

* feat(manga): UX mediums - richer detail page, smarter list, manga sort scope

Second batch from the manga UX review (M1-M7):

- M1: multi-chapter volume sections are collapsible (fully read sections
  start collapsed) with sticky headers, and long series get a 'Jump to
  <next unread>' anchor above the list
- M2: the series hero shows the author line (HeroCrewLine learns Author
  credits with person links; DetailHero now renders crewLine and genre
  chips independently) and Volumes/Chapters badges
- M3: browse-card count chip abbreviates to '12 Vol - 3 Ch' so it fits
  narrow cards without occluding covers
- M4: manga gets its own sort scope: Duration/Bitrate (meaningless for
  file-less series rows) disappear, reading labels (Date Read / Reads)
  apply, Author stays
- M5: global search labels manga results 'Manga' instead of the raw type
- M6: chapters carry the viewer's reading fraction; part-read rows show an
  inline progress bar + percent
- M7: chapter rows show the extracted cover thumbnail (presigned
  poster_url on the chapters payload) instead of a generic icon

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

* fix(manga): UX lows - volume token dedupe, comic reader chrome, empty-state hint

- buildMangaList buckets volumes by canonical numeric token so mixed
  release naming (v01 + 1) yields one Volume 1 instead of duplicates
- cbz/cbr readers start with the side panel closed and hide prose-only
  chrome (reading ruler, TTS, typography/font controls, hyphenation,
  writing mode) while keeping comic-relevant settings (theme, brightness,
  margin, right-to-left, spread, flow)
- manga empty state mentions chapters appear after the library scan

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

* feat(manga): publication status badge via new SDK status field

- vendor the unpublished plugin SDK (adds MetadataItem.status) under
  internal/compat/ with a relative go.mod replace, following the
  zishang520-webtransport-go convention; swap to the published module
  before the upstream PR
- map plugin status into MetadataResult.ShowStatus, persist it during
  manga enrichment, and show it as the hero status badge (show_status was
  already on the detail payload and MetadataBadges)

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

* feat(manga): generalize backdrop pass to secondary fields (backdrop + status)

The backdrop-only claim arm becomes a secondary-fields pass: enriched items
missing a backdrop and/or publication status are claimed, fetched by stored
provider ID, and only the missing secondary fields are written. Lets the
new status field backfill across the already-enriched library instead of
applying only to future enrichments.

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

* fix(metadata): merge ShowStatus through MergeMetadata/MergeGlobalMetadata

The new MetadataResult.ShowStatus never reached the accumulated result the
manga enricher persists from - the field-by-field merges didn't know it, so
the status backfill pass obtained nothing. Regression-tested on both paths.

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

* fix(manga): keep scanner identity IDs out of the metadata flow

filterMangaProviderIDs passed the scanner's manga_series identity row
through, so the search-skip-when-already-matched guard saw provider IDs on
every item and never searched: unmatched items went straight to a by-ID
fetch with no usable ID and were stamped as terminal no-match without a
single provider request (and the MangaDex fallback was never consulted).

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

* chore: gitignore docker-compose.override.yml (local deployment override)

The override unpublishes the bundled redis/postgres host ports
(ports: !override []). It is a per-deployment, local-only file: ignoring it
keeps a rebase from main and git clean -fd from disturbing it, and keeps it
out of any PR. Its accidental absence once exposed Redis to the internet.

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

* fix(manga): code-review fixes — no-match guard, sort comparator, volume-count consistency

- enrichWithProviders: set accumulator.HasMetadata after a provider result
  merges (MergeMetadata doesn't propagate it). Without this, a confident
  match carrying only genres/authors/status/year but no cover and no overview
  failed the no-match check and was discarded + terminally stamped.
- byChapterIndex: both un-indexed chapters yield POSITIVE_INFINITY, so the
  subtraction was Infinity-Infinity=NaN (Array.sort treats NaN as 0, leaving
  order undefined). Compare explicitly for a stable order.
- MangaContent volume/chapter badges: derive counts from the rendered
  buildMangaList entries (which canonicalize v01 ≡ 1) instead of raw distinct
  volume tokens, so the badge can no longer say '2 Volumes' over one row.

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

* docs(manga): clarify the enrichment claim's secondary arm is admin-reset-only

The secondary arm (poster present, backdrop/status missing) requires
last_refreshed IS NULL, so it is only reachable when an operator resets
last_refreshed to backfill a newly-added field — not an automatic periodic
re-check (which would re-fetch banner-less series every sweep). Documents the
intent so it does not read as dead code.

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

* fix(manga): collapse continue-reading chapters per series; batch provider-id lookup

- Continue Reading now collapses multiple in-progress chapters of the same
  manga into one card (most recently read kept), mirroring the episode→series
  collapse. The reading section resolves chapter→series linkage into itemMeta
  (applyMangaChapterSeriesMeta) and runs the shared
  collapseContinueWatchingSeriesCandidates, which the reading path previously
  skipped.
- claimBatch resolves provider IDs for the whole batch in one query via the
  new ProviderIDRepository.GetByContentIDs (content_id = ANY), replacing the
  per-item GetByContentID N+1.

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

* feat(web): manga publication-status chip on browse cards + more legible chips

- Color-coded publication status pill (Ongoing/Completed/Hiatus/Cancelled/
  Upcoming) in the manga card's top-left corner, mirroring the vol/chapter
  count chip top-right. Strictly manga-gated; show_status was already on the
  browse payload.
- New .glass-chip (78% surface vs glass-subtle's 40%) for the manga count +
  status pills so the labels stay legible over busy cover art.

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

* build(manga): depend on published silo-plugin-sdk v0.7.0

Replace the vendored internal/compat/silo-plugin-sdk copy with a normal
dependency on the published SDK module at v0.7.0, which adds
MetadataItem.status (publication/airing status) consumed by the manga
status badge at internal/metadata/plugin_provider.go.

- go.mod: pin v0.7.0, drop the local-path replace directive
- remove the vendored internal/compat/silo-plugin-sdk tree
- Dockerfile: drop the vendored-SDK COPY
- strip the manga design docs/plans from docs/superpowers (internal)

Requires Silo-Server/silo-plugin-sdk#4 merged and tagged v0.7.0.

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

* fix(manga): exclude chapters from the matcher's unmatched-item lister

Manga chapters are type='ebook' items that stay status='pending' by
design - provider metadata lives on the type='manga' series item. The
scan-final RetryUnmatchedItemsByFolderAndPathPrefix listed all of them
and ran a rate-limited ebook-plugin search per chapter: 31,564 chapters
x ~1s = 8h46m appended to a 2-minute manga library scan (observed
live), every one a guaranteed no-match. Earlier runs never survived to
completion, so the library's last_scanned_at stayed NULL forever.

Add the same manga_chapters NOT EXISTS guard the ebook enricher's
claim query already uses. Verified live: the same library now scans in
27s with retried_items=0.

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

* fix(scanner): never probe-repair ebook/comic files (ebook+manga detail-page killer)

NeedsCriticalProbeRepair was always true for BaseType 'ebook' files (epub, pdf,
cbz, cbr — incl. manga chapters): buildEbookMediaFile leaves ProbeUpdatedAt nil
and they have no audio/video, so probeEnsurer.Ensure spawned ffprobe per file on
every detail/watch load and never converged (ffprobe errors on zip/rar, result
never persisted). Short-circuit probe-repair for ebook base type — they're read
directly and never use the transcode/playback probe pipeline.

SHARED fix: benefits both the ebooks and manga library types.

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

* perf+fix(ebooks): parallelize detail extension + preserve finished read-state

- buildEbookExtension ran its 3 related-content queries (series, also-by-author,
  similar) sequentially; run them concurrently like buildAudiobookExtension so
  ebook detail latency is the slowest query, not their sum.
- PGEbookReaderProgressStore.Upsert did an unconditional SET progress=EXCLUDED;
  a routine autosave (e.g. reopening a finished book) could drop it below the
  0.9 finished threshold and silently un-mark it read (and clear the manga
  chapter checkmark, which rides on the same row). Guard: once finished,
  progress only moves on an explicit unread (row delete); below threshold it
  tracks freely.

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

* perf(manga): batch chapter presign, index volume counts, quiet scan log

- fetchMangaChapters presigned each chapter poster individually; a long-running
  series has hundreds of chapters. Batch them in one PresignImageURLs call, and
  add the missing rows.Err() check (was silently returning partial lists).
- The browse manga count chip's count(DISTINCT volume) subquery wasn't covered
  by manga_chapters_series (series_content_id, chapter_index); add
  idx_manga_chapters_series_volume (series_content_id, volume) so both count
  subqueries are index-only.
- Downgrade the per-chapter "manga scan: indexed" log from Info to Debug (one
  line per .cbz; the 500-file progress log already covers operator visibility).

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

* fix(manga): address PR #138 code-review findings

Folds PR #142 into the manga branch (already done via fast-forward) and
remediates the issues surfaced in the #138 code review.

Correctness:
- Preserve the scanner's manga_series identity anchor through enrichment.
  ReplaceByContentID's DELETE was unconditional, so the first successful
  enrichment wiped the manga_series provider-id row the scanner relies on
  for idempotency, causing duplicate series + metadata loss on the next
  scan. excludedProviderIDs now also means "not deleted", and the DELETE
  preserves those rows. (internal/catalog/provider_id_repo.go)
- Fall back to the series cover when the latest chapter has no poster.
  Poster columns default to '' (not NULL), so the manga series-card poster
  override blanked cards via a plain COALESCE; wrap operands in NULLIF.
  (internal/sections/fetcher.go)
- Keep backTo a real query param on reader links when libraryId is absent.
  It was string-concatenated with '&', producing a malformed URL on
  deep-links; route it through the query helper instead.
  (web/src/lib/mediaNavigation.ts, EbookReader.tsx, MangaContent.tsx)

Quality:
- Hide manga chapters from favorites/watchlist browse, matching the
  exclusion enforced on every other listing surface.
  (internal/catalog/favorites_browse.go)
- Centralize the manga chapter exclusion predicate into a single exported
  catalog.MangaChapterExclusionWhere, removing four duplicated copies.
  (catalog, sections, ebooks)
- Skip the two manga count subqueries on browse scopes that cannot contain
  manga (non-manga type filters), substituting NULL placeholders.
  (internal/catalog/browse.go)
- Normalize provider publication status (AniList/MangaDex/SDK variants)
  into a stable label set so show_status carries one manga value-domain.
  (internal/manga/enrichment.go)

Adds unit tests for the poster NULLIF contract, browse gating, and status
normalization.

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

* chore: regenerate go.sum after rebase onto main

Drops stale silo-plugin-sdk v0.6.0 and other leftover hashes from the
intermediate rebased states; go.mod is now on the published v0.7.0 tag.

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

* fix(scanner): adapt manga scan to ebookFileShouldSkip 3-value signature

main changed ebookFileShouldSkip to also return the existing content ID;
the manga scan path only needs the unchanged flag, so discard the new
return. Resolves a silent semantic conflict from the rebase onto main.

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

---------

Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:13:10 -04:00
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00
Quick 9508449859 feat(web): add settings search command palette 2026-06-12 12:47:55 -04:00
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).

- Extend release_events with a kind discriminator and add a movie
  availability spine (movie_availability + kind-keyed
  notification_content_seed_state; first full scan seeds silently so
  upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
  grouping, per-channel backoff, and auto-disable; request events post
  best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
  SSRF guard, embed limits, HMAC signing; shared type/name validation
  extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
  Channels section in the notifications admin settings UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:04:35 -04:00
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00