Commit Graph
53 Commits
Author SHA1 Message Date
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
b8b708cbe0 fix(watchsync): respect MDBList rate limits and defer syncs on 429 (#309)
* fix(watchsync): respect MDBList rate limits and defer syncs on 429

MDBList caps API usage at 1,000 requests/day on the free tier, and a
large-library first sync (paginated watched/watchlist fetches plus
exports chunked at 100 items per POST) could blow through it. A 429 was
treated as a generic failure: every pending chunk was marked failed and
the next scheduled run replayed the whole sync into the same limit.

- Pace MDBList requests at ~1/s per API key and retry 429s with a short
  Retry-After in place; longer waits surface a typed RateLimitedError.
- Abort the remaining sync flows on the first rate-limited flow and
  persist rate_limited_until on the connection; scheduled syncs skip it
  until the deferral passes and manual sync returns a proper cooldown.
- Leave rate-limited exports pending instead of marking them failed so
  the next run resumes where it stopped.

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

* fix(watchsync): address rate-limit review feedback

- Floor RateLimitedError.RetryAfter to the default deferral when in-place
  retries are exhausted, so untrustworthy short Retry-After hints can't
  produce a seconds-long deferral that walks straight back into the limit.
- Stamp rate_limited_until on every connection bound to the same provider
  account (the quota belongs to the API key, not the profile), and re-read
  connections mid-batch in SyncDueConnections so siblings deferred after
  the snapshot are skipped.
- Filter deferred connections out of the live dispatch queries
  (local watch events, list events, scrobbles) so real-time exports stop
  burning quota during a cooldown.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:58:19 -04:00
a5fb16a5c5 feat(historyimport): import the Plex account watchlist alongside watch history (#285)
* feat(historyimport): import the Plex account watchlist alongside watch history

The Plex import migrated only watch history; the user's saved watchlist
had to be rebuilt by hand (#245).

- PlexClient gains FetchWatchlist: pages the account-level watchlist on
  the Plex discover API (discover.provider.plex.tv). It authenticates
  with the plex.tv ACCOUNT token — the PIN/OAuth session token, which
  resolvePlexAuth now threads through plexAuth.AccountToken (manual-token
  imports pass the user token, which doubles as the account token).
- Watchlist entries become import Records flagged Watchlisted, carrying
  identity only (movie/show → KindMovie/KindSeries, guids parsed) and no
  watch state. They ride the existing matcher (series matching already
  exists), and matched entries are added to the importing profile's
  watchlist via the idempotent AddToWatchlistAt — re-imports do not
  duplicate. A watchlist fetch failure downgrades to a run warning so the
  history import still completes.
- Run summaries gain a watchlist_added counter (new column + repo
  plumbing + client/admin UI cards).

Fixes #245

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

* fix(historyimport): count only newly inserted watchlist rows in WatchlistAdded

AddToWatchlistAt now reports whether a row was actually inserted (the
insert is ON CONFLICT DO NOTHING / INSERT OR IGNORE), and the import
summary increments WatchlistAdded only for genuine inserts, so
re-importing the same Plex account no longer inflates the count.

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-05 00:19:31 -04:00
97ac2b4eed feat(audiobooks): audiobookshelf support — ABS conformance, perf, ebooks (#289)
* fix(ebooks): fold author hint into metadata search query

The ebook enricher loaded each item's author but buildEbookSearchQuery
dropped it, and metadata.SearchQuery had no field to carry it — so the
plugin only ever received the title. Title-only searches collide or miss,
leaving items without metadata or a cover.

Add SearchQuery.Author and fold it into the plugin search query text
(the SearchMetadataRequest contract carries a single free-text Query, so
no proto change is needed). Gated to callers that set Author (ebooks);
movie/TV search is unchanged.

Verified live against OpenLibrary/GoogleBooks: improves disambiguation on
clean titles. Note: messy filename-derived titles (series prefixes,
trailing "(… Book N)") still need title normalization, and a large tail
of niche/self-published ebooks is simply absent from the free sources —
neither is addressed here.

AI-use disclosure: authored with Claude Code.

(cherry picked from commit ba1265909c4fb87e1a8eab64b0b0c183aa95acc1)

* feat(scanner): extract MOBI/AZW/AZW3 metadata from EXTH headers

These formats previously had no parser — parseEbookFile returned only the
format string, so title fell back to the filename with no author and no
ISBN, leaving ~21k books unmatchable by the metadata enricher.

Parse the Palm Database container (PDB header → record 0 → PalmDOC +
MOBI header → EXTH block) and extract title, authors, ISBN, publisher,
and language. EXTH is located by its magic rather than the header flag,
and field offsets (encoding @12, full-name @0x44/0x48) were verified
against real .mobi/.azw3 files.

Verified live against real library files:
  azw3 → title "The Sea", author "A H Lee"
  mobi → title "Brotherband 3: The Hunters", author "John Flanagan",
         ISBN 9781742750637

AI-use disclosure: authored with Claude Code.

(cherry picked from commit 7af194b711de97bc79855f08a9a4f9732c49db74)

* fix(ebooks): recover author from path and clean provider search title

- ebookAuthorFromPath: recover an author for ".../<Author>/<Title>/<Title> -
  <Author>.ext" layouts when the file embeds none, gated on two agreeing
  path signals (grandparent dir == filename suffix) so magazines/courses
  never get a junk author; strip the suffix from a path-derived title.
- cleanEbookSearchTitle: normalize filesystem-mangled titles before search
  (underscore->space, drop trailing " - <author>") to lift hit rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 36a16cb58c3e5276aa4c0bdf8577008070f6abea)

* fix(scanner): gate path-author on person-name shape

ebookAuthorFromPath's grandparent==suffix corroboration also matched
inverted layouts ("<Title>/<Author>/<Author> - <Title>"), assigning the
title as the author. Require the candidate directory to look like a person
name (comma form, or all-capitalized tokens plus name particles) so series
and title folders ("De legenden van de Alfen") are rejected, and return the
canonical directory form for proper casing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit ff720bd268a23bff0e94c70f15cb7ecfb8efcb1f)

* fix(ebooks): strip series/book-number parentheticals from search title

cleanEbookSearchTitle now peels trailing "(... Book N)", "[#3]", "(2019)"
groups that don't belong in a provider title query, while leaving
meaningful parentheticals ("(Illustrated)") intact. Enrichment-side only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 2273636c04fd9ef483003a9558972a2104fdd3a6)

* fix(ebooks): keep volume number in search title and dedup provider IDs

Two distinct ebooks (e.g. series volumes named only by series + book
number) were collapsing onto a single provider work, then fighting over
the same media_item_provider_ids row:

- cleanEbookSearchTitle stripped trailing "(... Book N)" / "[#3]" groups
  entirely, so every volume of a series searched as the bare series name
  and matched the same provider work. The plugin search contract carries
  only a single free-text Query, so the volume number is now UNWRAPPED
  into the query (brackets dropped, words kept) instead of discarded,
  giving distinct volumes distinct searches. Bare-year groups are still
  dropped (SearchQuery.Year carries them); meaningful parentheticals
  ("(Illustrated)") still survive.

- collectEbookMetadata now consults FindContentIDByProviderIDs before
  accumulating a search-result provider ID. An ID already owned by a
  different content item is skipped, so the loser is not mis-tagged with
  the winner's metadata and ReplaceByContentID no longer violates the
  (provider, provider_id, item_type) unique constraint. The previous
  behavior logged duplicate-key errors every sweep and re-enriched the
  failing item forever (CPU/RAM churn). A failed ownership check is
  surfaced as a provider error so the item retries rather than terminally
  stamping as "no match".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 942fdef6cb0e167b2d9223e9a968b3010b7b3ec8)

* fix(ebooks): address CodeRabbit review on PR #185

- cleanEbookSearchTitle: anchor author-suffix strip to a trailing match
  (optionally followed by a series/volume parenthetical) so a mid-title
  " - <token>" no longer truncates valid title text
- ebook scan: strip the recovered author suffix using normalized comparison
  so case/spacing variants (e.g. "a. f.  carter") don't leave a duplicate
- parseMOBIEXTH: bound parsing to the declared EXTH length so a corrupt
  record count can't read full-text bytes as junk metadata
- add regression test for a non-trailing " - <token>" in the title

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0f45af8143e04dfd5b4a5ee3e949dcd943eedbd1)

* fix(audiobooks): pass author in search query and retry on provider errors

Set SearchQuery.Author so the host adapter folds author into the
plugin free-text query (parity with ebooks). Track provider errors
during enrichment; when nothing matched and a provider errored, return
an error without stamping last_refreshed so the sweep retries instead
of terminally burning the item on a transient failure.

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

* fix(scanner): consolidate fragmented multi-file audiobook content_ids on rescan

audiobookFolderShouldSkip used ListByObservedRootPath which returns all
files for a root path regardless of content_id. When a multi-file audiobook
had files fragmented across multiple content_ids (e.g. from concurrent
refreshes), the file count matched disk so the skip check returned true
and the reconcile never ran to merge them.

Now verifies all DB files share the same content_id before skipping; any
fragmentation forces a full reconcile which consolidates to one content_id
via FindContentIDByRootPath → upsertAudiobookMediaFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit e91c33e88a7d09e802e6afd8af246c6c954d0498)

* fix(ingest): skip concurrent match drainer for audiobook/podcast/ebook/manga libraries

The concurrent scoped match drainer ran during scan for all library types.
For audiobook libraries, the scanner assigns content_ids by folder root
(one item per multi-file folder). Running the drainer concurrently caused
it to process files with content_id=NULL (cleared by complete refresh)
as individual items, creating one media_item per file instead of one per
folder. This manifested as 41-file audiobooks fragmenting into dozens of
orphaned single-file content_ids on every refresh.

These library types use scanner-driven grouping; the post-scan drain step
handles them correctly. Returning nil matchScopes skips the concurrent
drainer entirely for these types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 93ae9d22ce315874fa22a958b88ca1766075695f)

* fix(abs): match real audiobookshelf auth + session-sync contract

Align the ABS-compat auth flow with real audiobookshelf (v2.26+) so
third-party clients (yaabsa, Plappa, native iOS) authenticate and sync
playback correctly:

- login/refresh: always emit user.accessToken; x-return-tokens gates
  only the refresh token (body vs HttpOnly refresh_token cookie)
- /auth/refresh returns the full login envelope (was a thin token map)
- /me returns the full user object (toOldJSONForBrowser), shared with
  login/authorize via a single absUserObject() builder
- /logout returns 200 {redirect_url:null} and clears the cookie (was 204)
- add POST /session/{sid}/sync (real ABS heartbeat path); it was
  PATCH-only, so the official client's sync POST 404'd and playback
  progress never synced

Verified against advplyr/audiobookshelf server/{Auth.js,models/User.js,
controllers,routers}. Unit tests updated/added; full abs suite green.
Not yet live-verified.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 336e932471d4be021d82299106a120611783836a)

* fix(abs): conform browse/list items to real audiobookshelf minified shape

Strict ABS clients (yaabsa, Plappa) crash or drop items when the browse
list shape only approximates real audiobookshelf. Match the serializers:

- add media.id + media.libraryItemId (= ContentID) to LibraryItemMedia;
  yaabsa BookMedia.id is required non-null and was missing → the whole
  item failed to parse ("Null is not a subtype of String")
- rebuild the minified list shape to LibraryItem.toOldJSONMinified +
  Book.toOldJSONMinified + oldMetadataToJSONMinified key-for-key (ino,
  path, isFile, numFiles/size, media.{id,tags,numTracks,numAudioFiles,
  numChapters,size,ebookFormat}, flat author/series metadata)
- force media.numTracks/numAudioFiles >= 1 in the browse projection so
  Plappa doesn't drop items reporting 0 audio files
- default /items list to minified (real ABS list is always minified);
  minified=0 opts into the full shape

Verified against advplyr/audiobookshelf models/{Book,LibraryItem}.js.
Adds minified_test.go key-set conformance guards; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6c9387a8c4b60be3dbe541049ed8c06344b10717)

* fix(abs): conform /items/{id} detail to real audiobookshelf expanded shape

Match real audiobookshelf LibraryItem.toOldJSONExpanded +
Book.toOldJSONExpanded + oldMetadataToJSONExpanded so strict clients
decode the item-detail page with the same model they use elsewhere:

- add expanded outer keys to LibraryItem (oldLibraryItemId, lastScan,
  scanVersion, libraryFiles, size) and populate libraryFiles + summed
  size from the item's media files in the detail builder
- add media.size (Book.toOldJSONExpanded)
- make the typed Metadata the full expanded superset: subtitle,
  titleIgnorePrefix, authorName, authorNameLF, narratorName, seriesName,
  descriptionPlain, publishedDate, asin, language, abridged; drop the
  omitempty that previously dropped description/publishedYear/isbn/
  publisher when empty (a missing key crashes strict clients)

Verified against advplyr/audiobookshelf models/Book.js + LibraryItem.js.
Adds items_detail_test.go expanded key-set guard; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8bd485f0291e9db9f32e13a68609a68ee8a945ec)

* fix(abs): conform authors/series endpoints to real audiobookshelf shapes

Match the real audiobookshelf serializers so strict clients decode the
authors/series browse + detail responses:

- GET /libraries/{id}/authors now branches like LibraryController.getAuthors:
  bare { authors: [...] } when not paginated, paged { results, total, ... }
  only when limit+page are present (was always paged → clients keying on
  `authors` got keyNotFound)
- author objects carry the full Author.toOldJSON key set (id, asin, name,
  description, imagePath, libraryId, addedAt, updatedAt, numBooks); silo has
  no analog for asin/description/imagePath/timestamps so they are null/0
- series objects carry the full Series.toOldJSON key set (adds
  nameIgnorePrefix, description, libraryId, addedAt, updatedAt)
- series/author books are now FULL minified library items (real ABS shape)
  instead of thin {id,media:{metadata:{title}}} stubs that crash Plappa;
  author items moved to the real-ABS `libraryItems` key

Verified against advplyr/audiobookshelf controllers/LibraryController.js and
models/{Author,Series}.js. Tests updated + envelope-branch guard added; abs
suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8a22eb0900ed881d500ded315a508e1a07da14f3)

* fix(abs): add libraryId to collection/playlist objects (real ABS shape)

Real audiobookshelf Collection.toOldJSON and Playlist.toOldJSON both carry
a libraryId; silo's emitters omitted it, so a strict client modeling the
object with a required libraryId crashed. silo collections/playlists are
cross-library user-personal, so emit the virtual audiobook library id.

The books[]/items[] entries already carry the full LibraryItem shape and
inherit the browse-conformance fixes (media.id etc.). Envelopes were
already correct (paged for library-scoped, {collections}/{playlists} for
global).

Verified against advplyr/audiobookshelf models/{Collection,Playlist}.js.
Envelope key-set tests updated; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

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

* fix(abs): conform library object + /libraries/{id} to real audiobookshelf

The library object was only {id,name,mediaType}; real audiobookshelf
Library.toOldJSON has 12 keys, so a strict client decoding the library
model crashed on the missing ones. Also GET /libraries/{id} always wrapped
the object in { library: ... }, but real ABS returns it directly unless
?include=filterdata is requested.

- audiobookLibraryMap now emits the full Library.toOldJSON shape (folders[]
  as LibraryFolder.toOldJSON, displayOrder, icon, provider, settings,
  lastScan, lastScanVersion, createdAt, lastUpdate). This also enriches the
  libraries[] on the login envelope, which shares the builder.
- handleLibraryDetail returns the library object DIRECTLY without include,
  and wraps in { filterdata, issues, numUserPlaylists,
  customMetadataProviders, library } (adds the missing
  customMetadataProviders) with include=filterdata.

GET /libraries already returned { libraries: [...] } (correct). Verified
against advplyr/audiobookshelf models/Library.js +
controllers/LibraryController.js. Adds libraries_shape_test.go; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

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

* fix(abs): conform personalized recent-series shelf to real ABS series shape

The /libraries/{id}/personalized "Recent Series" shelf emitted thin
{id,name,numBooks,libraryId,books:[]} entities with an always-empty cover
stack. Emit the full real-ABS series object (seriesObjectABS, adds
nameIgnorePrefix/description/addedAt/updatedAt) with minified book items
(seriesBookMinified) — the same shape as /libraries/{id}/series so the
shelf card decodes identically and shows real covers.

Book shelves already used full minified items; the shelves array is a bare
array (matches real ABS getUserPersonalizedShelves). abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7c586f8c923cbc481f6f94e304af48dee379a999)

* fix(abs): conform listening-sessions to real audiobookshelf PlaybackSession shape

silo's /me/listening-sessions returned a thin 5-field session object
(id, libraryItemId, userId, timeListening, currentTime) wrapped in the
generic pagedEnvelope shape ({results,sortBy,filterBy,minified}). Real
audiobookshelf clients (Flutter/Swift strict decoders) expect the
MeController.getListeningSessions envelope
({total,numPages,page,itemsPerPage,sessions}) and each session to carry
the full PlaybackSession.toJSON() key set, so the missing keys (notably
mediaType, mediaMetadata, displayTitle, displayAuthor, coverPath,
duration, chapters, deviceInfo, playMethod, mediaPlayer, serverVersion,
date, dayOfWeek, startTime, startedAt, updatedAt, libraryId, bookId,
episodeId) crashed with keyNotFound errors.

Both handleListeningSessions and handleListeningSessionDetail now build
the response via a shared sessionToABS() that reuses
buildSiloPlayMediaMetadata (already used by /play) to hydrate
mediaMetadata/displayTitle/displayAuthor from MediaStore, batching
lookups via GetAudiobooksByIDs for the list endpoint. Lookups are
best-effort: a missing/inaccessible item falls back to a stub
MediaItem so every key is still emitted, never a crash.

Verified against advplyr/audiobookshelf server/controllers/MeController.js
(getListeningSessions) and server/objects/PlaybackSession.js (toJSON())
on GitHub master.

Known placeholders (real ABS fields we can't populate without extra
cost): chapters (empty array — would require a per-session media-files
fetch), duration (0 — total book duration isn't tracked on the session
row), startTime (0 — not persisted separately from currentTime),
deviceInfo (static "unknown" device, matching the /play endpoint's
existing placeholder — no device info is persisted per session).

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9471497c99b96c8d3defc6c8112c913f7c55924b)

* feat(abs): add offline session sync endpoints (/session/local, /session/local-all)

The official ABS mobile app records playback while offline and POSTs those
PlaybackSession objects back on reconnect via SessionController.syncLocal and
syncLocalSessions. silo was missing both endpoints, so offline listening
progress was silently lost. Add them to the bearerAuth-protected session group
(both /abs/api and /api prefixes) alongside /session/{sid}/sync and /close.

POST /session/local decodes one PlaybackSession and updates the caller's resume
position via ProgressStore.UpdateProgressPosition (the same call handleSessionSync
uses), emitting user_item_progress_updated. POST /session/local-all decodes
{sessions:[...]} and loops each robustly — a malformed or unknown item marks that
one result failed without sinking the batch — returning {results:[...]}. No new
store persistence or migration; podcast/episode sessions are accepted as no-ops.

Verified against advplyr/audiobookshelf server/controllers/SessionController.js
and server/managers/PlaybackSessionManager.js.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 008a4df948d855a4bfe62b24f89bfc484f088033)

* fix(abs): conform library search + items-in-progress to real audiobookshelf

Real ABS's libraryItemsBookFilters.search() (delegated from
LibraryController.search) returns { book, narrators, tags, genres,
series, authors } with no "podcast" key for a book library, and each
book entry is only { libraryItem } — no matchKey/matchText, which our
handler was inventing. Search now matches those keys, drops the
fabricated matchKey/matchText fields, and best-effort populates
authors/series buckets via client-side substring filtering over the
existing aggregate listers (narrators/tags/genres stay empty-but-present
since silo has no backing aggregation query for them yet).

MeController.getAllLibraryItemsInProgress wraps items as
{ ...libraryItem.toOldJSONMinified(), progressLastUpdate }; our handler
was emitting a hand-rolled subset of fields plus a nested
userMediaProgress object that doesn't exist in the real response.
items-in-progress now reuses the existing Minify() projection and merges
a flat progressLastUpdate (ms) field to match.

Verified against advplyr/audiobookshelf controllers/{Library,Me}Controller.js
and server/utils/queries/{libraryItemsBookFilters,authorFilters}.js.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 998ff55f3cf27d504f0e5aec8c7c29fa57f10247)

* fix(abs): /ping returns success:true and /status carries authMethods

The ABS apps validate a server address by reading response.success from
GET /ping; silo returned {pong:true,...} with no `success`, so the app
reported "unable to reach" even though the server responded 200. Also
/status was missing authMethods/authFormData, which the app reads to render
the login form.

- /ping now includes {"success": true} (pong/server/version kept as extras)
- /status now returns {app,serverVersion,isInit,language,authMethods,
  authFormData} matching real audiobookshelf Server.js

Verified against advplyr/audiobookshelf server/Server.js. Adds
ping_status_test.go; abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

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

* fix(abs): mount login + auth/refresh under /api prefix

Clients that post to /api/login (and /api/auth/refresh) got a 404 because
login/refresh were only mounted at root and /abs/api — while the rest of the
authenticated ABS surface (/api/me, /api/authorize, /api/libraries, covers)
is served under both /api and /abs/api. The 404 surfaced in the client as a
generic "unknown error occurred" on sign-in.

Mount /login and /auth/refresh under all three prefixes ("", /api, /abs/api),
matching the authenticated groups.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 18071e180b02131cda354303eadd1ff3a0708065)

* fix(abs): accept form-encoded login bodies (not just JSON)

Real audiobookshelf (express body-parser + passport local) accepts both
application/json and application/x-www-form-urlencoded credential bodies.
Silo only json-decoded the body, so a form-encoded client got 400 "invalid
request body" — surfaced in the app as a generic "unknown error" on sign-in
(confirmed live: JSON creds -> 200, identical form-encoded creds -> 400).

Buffer the body once, try JSON, then fall back to url.ParseQuery for the
form-encoded case.

Adds login_body_test.go (form + JSON both reach the validator). abs suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 408dc33debb7a7ee363778094511ccfdd1ee70d2)

* fix(abs): emit full real-ABS serverSettings (OpenID/auth fields)

silo's login/authorize serverSettings omitted the auth + OpenID fields that
real audiobookshelf ServerSettings.toJSONForBrowser includes
(authLoginCustomMessage, authOpenID*, rateLimitLogin*, backupPath,
allowedOrigins). OIDC-aware strict clients (Prologue, iOS/Swift) decode
serverSettings into a model that requires those keys, so their absence throws
keyNotFound and the ENTIRE login response fails to decode — the client stays
on the login screen with a generic "unknown error" even though the server
returned 200. Simpler clients that don't model OpenID were unaffected.

Emit real ABS's OIDC-disabled defaults; authActiveAuthMethods still advertises
only "local" so no client initiates the OpenID flow.

Diagnosed from a packet capture (Prologue posts /login? with X-Return-Tokens
and gets a 200 it can't decode) + real ABS ServerSettings.js. Verified against
advplyr/audiobookshelf.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 283826c952824e97233e46e057f37385ddc3054b)

* fix(abs): GET /me returns the real display username, not the userID

/me built its user object from the token claims and passed the numeric
userID as the username, so clients saw "98" instead of "puksthepirate".
Login gets the display name from the credential validator, but /me only has
the token, so it needs a lookup.

Add an optional UsernameResolver to the abs Dependencies; wire it from the
concrete SiloCredValidator (which holds the pgx pool) via a new
ResolveUsername method that mirrors Validate's display-name logic — the
profile name when a profile is set and named, else the account username.
handleMe uses it and falls back to the userID when unresolved.

abs package compiles + tests pass; the audiobooks package (service.go,
cred_validator.go) could not be linked locally (pre-existing bimg/libvips
pkg-config gap) and is validated at the Docker build.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 39ff3e3350aad087757f7fb0e94d5a7f10c08ae5)

* fix(abs): always emit AudioTrack keys + correct media.duration

Two item-detail issues that made Prologue report "Unable to load book
contents" (can't press Start Listening):

- AudioTrack used omitempty on chapters/metaTags/format/bitRate/codec/
  metadata/etc, so empty values dropped those keys. Real ABS AudioFile/
  AudioTrack always emit them; strict clients (Prologue, yaabsa) decode
  tracks into a required-field model and throw keyNotFound on the missing
  keys, failing the whole track decode. Removed omitempty and emit
  chapters/metaTags as [] / {} (non-nil) in both track builders.
- media.duration used the item's Runtime, which is often stale/mis-scanned
  (e.g. 222s for a 3.7h book) and desyncs the player scrubber. Now sum the
  track durations (real ABS: sum of audio file durations), falling back to
  Runtime only when there are no tracks.

Verified against advplyr/audiobookshelf models/Book.js (AudioFile/AudioTrack)
via a live packet capture of Prologue's item-detail decode failure. abs
suite green.

AI-use: implemented with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8210ed2fc63e782f158b4168ef693e671ae19638)

* perf(abs): push down library browse filters + author counts MV

The ABS audiobook library-serving path was slow on large libraries
(~255k items): /libraries/{id}/items?filter=authors.{id} loaded and
hydrated the whole library into Go before filtering (~4.8s each), and
/libraries/{id}/authors ran a full GroupAggregate + COUNT(DISTINCT)
per page (~53s full sync) — slow enough to trip ABS client sync
timeouts (e.g. Prologue).

- Push author/series/narrator/no-series filters into indexed SQL
  EXISTS predicates in ListAudiobooks; paginate + COUNT in SQL.
  Semantically equivalent to the prior Go-side filter (kind=7 author,
  kind=8 narrator, exact-case match, no-series sentinel).
- Add covering index media_items(content_id, type) so the count/list
  type check runs index-only (CONCURRENTLY, NO TRANSACTION — no
  write-lock on the live table).
- Serve /authors from a materialized view (abs_audiobook_author_counts)
  refreshed every 15min, with a live-query fallback when the view is
  empty/unrefreshed so the endpoint never blanks on a fresh deploy.

Conformance preserved: keeps authorObjectABS/seriesObjectABS shapes and
the limit&&page envelope decision; adds a regression test for the
bare {authors:[...]} envelope on limit-only requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0d55754051dd3ad016b3cec6a0921307071d3219)

* perf(abs): index-back audiobook search via trigram GIN

SearchAudiobooks matched the raw media_items.title with ILIKE '%q%'
OR'd with an author/narrator EXISTS. The un-indexed raw-title column
plus the OR forced a full seq scan of the ~255k-item library on every
search (~560ms on library 18).

Reshape into a UNION of two index-driven arms that reuse the search
infrastructure the rest of the catalog already relies on: the title arm
matches media_items.title_normalized (idx_media_items_title_normalized_trgm)
via the shared normalize_search_text(), the people arm matches people.name
(idx_people_name_trgm). GROUP BY content_id keeps the best rank when an
item matches both; a normalize_search_text($2) <> '' guard stops a
punctuation-only query from degenerating into ILIKE '%%'.

No new index or migration — the trigram indexes already existed and were
simply unused. ~560ms -> ~35ms, both indexes engaged, no seq scan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 98bbd1712719ecd03e4db87f840774cec788f177)

* perf(abs): index-ordered item paging + cached library count

The unfiltered /libraries/{id}/items path that ABS clients page through
to sync a library recomputed COUNT(*) over the whole library on every
page (~150ms each) and ordered by LOWER(sort_title), LOWER(title) — an
expression matching no index, forcing a full in-memory sort of all
~255k rows per page (~324ms shallow, ~543ms deep). A full sync is
thousands of pages, so both costs dominated indexing time.

- Order by lower(coalesce(nullif(btrim(sort_title),''), title)),
  content_id so the page is served by an ordered index scan on the
  existing idx_media_items_sort_key (~324ms -> ~1ms). content_id (PK)
  is a stable tiebreaker, making sequential pagination deterministic —
  the prior ordering could skip/repeat rows when sort keys collided.
- Memoize the per-page COUNT in a 60s TTL cache keyed on the fully
  rendered count SQL + bound args, so it covers every input the WHERE
  depends on (library, pushed-down filter, all access predicates) and
  can't drift as access logic evolves. Expired entries swept on write.

No new index or migration — reuses idx_media_items_sort_key.
total may lag up to 60s during an active scan; clients re-sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 32e26c2f99a1ffc071f600071c2ea7ddcd3397b4)

* fix(abs): address PR review — access-aware authors, offline progress create, cookie refresh, body limits

- media_store: ListLibraryAuthors bypassed per-item access when reading the
  author materialized view (keyed by library only), leaking authors of books
  hidden by a content-rating cap or excluded media types. Take the access-aware
  live path whenever the filter carries an item-level predicate.
- session_local: offline sync used UPDATE-only UpdateProgressPosition, so a book
  listened to entirely offline (no progress row yet) had its position silently
  dropped while still reporting progressSynced. Create the row via UpsertProgress
  when none exists; keep the monotonic update path for existing rows.
- login: handleRefresh never read the refresh_token cookie, so cookie-flow ABS
  clients got 400 refreshToken required once the access token expired. Read the
  cookie as a third source after header and body.
- session_local: cap /session/local and /session/local-all request bodies at
  1 MiB via io.LimitReader, matching the rest of the package.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:16:34 -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
604bbf1a0f feat(playback): unified restart-resilient playback (native + jellycompat) (#174)
* feat(playback): unified restart-resilient playback via shared TranscodeManager

Make direct, remux, and native HLS transcode sessions survive a server
restart through one shared flow instead of per-method paths. A missing
in-memory session becomes a reconstruct trigger, not a 404: the server
rebuilds the session from a tiny durable recipe card plus the position the
client re-supplies on its next request.

- internal/playback/transcode_manager.go: shared TranscodeManager owning the
  transcodes map, recipe-card lifecycle, reconstruct single-flight +
  concurrency cap, LoadOrReconstructSession front door, ReconstructSession /
  ReconstructTranscode, and orphan cleanup. ~90% is logic moved out of the
  native handler (no behavior change), not new surface.
- internal/playback/recipecard.go + recipecard_postgres.go: RecipeCard with a
  PlayMethod discriminator (direct/remux/transcode; empty decodes as transcode
  for back-compat) behind a swappable, nil-safe RecipeStore interface backed by
  transcode_recipes.
- internal/playback/session.go: RegisterReconstructed inserts a rebuilt Session
  under its existing id (no UUID mint, no limit double-count, race-yielding).
- internal/playback/transcode.go: CloseProcess keeps the output dir so a
  reconstruct winner keeps serving; Close removes it.
- internal/api/handlers: drain the transcode lifecycle into the manager; wire
  reconstruct into the stream/segment serve paths; re-bind ownership to the live
  caller (refuse userID==0/mismatch); card-aware orphan cleanup.
- migrations: add transcode_recipes (expires_at TTL, filter-on-read, indexed).

Ownership stays two-factor: an authenticated caller AND a session.UserID that
matches; the card stores no secrets and identity is re-resolved per request.

Tests: recipe-card round-trip/legacy-decode/disabled-noop, RegisterReconstructed
insert/race/concurrency, close-vs-close-process dir semantics, the
LoadOrReconstructSession status matrix, and the reconstruct concurrency cap.

AI-use: implemented with AI assistance (design, implementation, adversarial review).

* feat(jellycompat): reconstruct transcodes across restart via shared manager

Bring Jellyfin (jellycompat) HLS playback onto the same restart-resilient flow
as the native path. Previously jellycompat owned a separate PlaybackHandler with
a private transcodes map and a duplicated transcode lifecycle that never grew the
reconstruct half, so an in-flight Jellyfin transcode died on restart and the next
segment request 404'd.

- Embed the shared playback.TranscodeManager and delete the duplicate lifecycle,
  so jellycompat gets reconstruct, the concurrency cap, the node-affinity rule,
  and the card lifecycle for free.
- internal/jellycompat/playback_sessions_postgres.go: DurableCompatPlaybackStore,
  a write-through cache over jellycompat_playback_sessions behind the new
  CompatPlaybackStore interface (nil pool degrades to cache-only). This persists
  the load-bearing PlaySessionId -> UpstreamSessionID mapping (plus media sources,
  route item id, seek) so it survives a restart instead of vanishing with the map.
- Write a recipe card on compat transcode start keyed by the upstream session id,
  using the native StreamAppUserID so the ownership re-bind matches; reconstruct
  the upstream session and the transcode seeked to the requested seg_NNNNN.
- migrations: add jellycompat_playback_sessions (expires_at TTL + compat_token
  index, full PlaybackSession in data JSONB).

Auth is mapped to the native user id before reconstruct so the same two-factor
ownership check and userID==0/mismatch refusal apply unchanged.

Tests: DB-gated (SILO_TEST_DATABASE_URL) durable-store round-trip proving a
session written by one instance reloads in a fresh one (the restart case), plus
a nil-pool cache-only path; existing handler tests updated to the manager.

AI-use: implemented with AI assistance (design, implementation, adversarial review).

* docs(playback): consolidate unified playback reconstruction design

Replace the three overlapping playback docs (the native Postgres
restart-resilience spec, the jellycompat plan, and the unification spec) with a
single self-contained design at
docs/superpowers/specs/unified-playback-reconstruct.md.

The doc leads with the unified design — the one-idea reconstruct model, a strong
visual flow of a restart mid-playback, the shared TranscodeManager + recipe card,
the two swappable durable stores, security, the concurrency cap and node-affinity
constraint, preconditions, and verification. The design history and rationale
(reconstruct-not-rehydrate, phased delivery, Redis-vs-Postgres, token-as-
descriptor, failure analysis) move to an appendix. It references no other md file.

AI-use: written with AI assistance.

* fix(playback): address review on restart-resilient playback

Four fixes from PR review of the unified reconstruction work:

- Rewrite the recipe card on audio-track change. HandleChangeAudioTrack only
  updated the in-memory session/transcode, so after a restart reconstruct
  resumed with the stale AudioTrackIndex/TranscodeAudio (and stale play method)
  from the start-time card. Re-save the card (direct/remux/transcode) with the
  switched state, mirroring the start-card pattern.
- Guard nil TranscodeManager in LoadOrReconstructSession and ReconstructSession.
  StreamHandler.TM is documented optional (tests/minimal setups); a missing
  session previously panicked in recipeEnabled instead of returning
  SessionMissing. ReconstructTranscode already guarded nil; make the two
  siblings consistent.
- Reject direct/remux cards in doReconstructTranscode before spawning ffmpeg, so
  a non-transcode card id can never enter the HLS reconstruction path.
- Log a non-success status from the remote transcode-node DELETE in
  CloseTranscodeSession; a 401/404/500 was previously silent.

AI-use: implemented with AI assistance.

* fix(playback): harden restart-resilient compat sessions

* feat(playback): token-carried reconstruction across restarts

Build on the shared TranscodeManager (introduced earlier in this branch) so a
playback session survives an API-server or transcode-node restart without the
client re-negotiating, and retire the Postgres transcode_recipes store in favor
of a recipe carried inside the signed stream token.

- RecipeCard encodes the byte-affecting encode parameters and rides inside the
  stream token; LoadOrReconstructSession rebuilds the in-memory Session (and,
  for integrated transcodes, the ffmpeg process) on a cold miss, single-flighted
  per session and paced by a spawn semaphore. Removes recipecard_postgres.go and
  the 20260617233705_add_transcode_recipes migration.
- transcodenode reconstructs a lost ffmpeg node-side from the forwarded token.
- TR-lease: proxy/streamauth enforce a revocation deny-marker on every served
  segment, with a 500ms Redis timeout, a bounded per-session "allowed" cache
  (3s TTL, expiry-first graceful eviction), and a degraded-fail-open counter.

Review hardening folded in:
- Manifest/segment handlers do the in-memory session lookup first and only
  verify the stream token on a reconstruct miss (token HMAC was per-segment).
- Copy-mode reconstruct never applies the encoded-only seg*dur seek, at spawn
  time or via the recovery path: RestartSeekTarget reports "unresolved" for a
  copy session whose manifest cannot yet map the segment, so the client retries
  instead of seeking to a fabricated source time.
- Crash teardown is a compare-and-delete (CloseTranscodeSessionIf returns
  whether it matched); the crash closure tears down the playback session only
  when it matched, so a session reconstructed under the same id is not killed.
- Reconstruct enforces the same per-user stream/transcode caps as a fresh start
  (RegisterReconstructedWithLimits), closing a token-replay slot bypass.

AI-use disclosure: implemented with AI assistance (Claude Code), including a
two-round multi-agent adversarial review whose findings drove the hardening.

* feat(jellycompat): node-side transcode reconstruct via shared recipe store

Make Jellyfin-compat playback sessions survive a server or transcode-node
restart by reusing the shared TranscodeManager reconstruct path and a durable
recipe store, on top of the durable compat session store added earlier in this
branch.

- Node-side transcode reconstruct goes through the shared recipe store; the
  recipe is persisted to the control-plane store (Redis) when a dedicated
  transcode node is used so the node can rebuild ffmpeg after its own restart.
- Adopt the shared manager's API (3-arg OnFFmpegCrash carrying the dead session,
  guarded CloseTranscodeSessionIf, RegisterReconstructedWithLimits).

Review hardening folded in:
- Recipe lifecycle: noderecipe.Store gains Delete, called on deliberate
  teardown (stop, method-switch discard, node stop/force-reload) so a stopped
  session cannot be resurrected by a buffered request after a node restart;
  crash paths intentionally keep the recipe so a resume can reconstruct.
- Crash closure tears down the upstream session only when the guarded transcode
  close matched, so a reconstructed successor is never left orphaned.
- Copy-mode segment recovery surfaces a retryable not-found instead of a
  wrong-position restart, matching the native and node paths.
- Durable Update is now a SELECT ... FOR UPDATE transaction, removing the
  lost-update clobber that could silently drop a transcode recipe.
- Empty-token route resolution no longer falls back to an unbounded full-table
  scan; DB expiry filters bind the injected clock; the redundant re-Get is gone.

AI-use disclosure: implemented with AI assistance (Claude Code), including a
two-round multi-agent adversarial review whose findings drove the hardening.

* docs(playback): consolidate restart-resilient playback design

Replace the superpowers spec with a single architecture record describing the
token-carried recipe card, the shared TranscodeManager reconstruct path for
direct/remux/transcode, the jellycompat durable session + node recipe store, and
the revocation-lease model with its fail-open tradeoff.

AI-use disclosure: written with AI assistance (Claude Code).

* docs(playback): correct jellycompat node-recipe rationale in comments

The noderecipe / transcode-node / jellycompat comments justified the Redis
recipe store with "a Jellyfin client cannot round-trip a token". The real
reason: the node-hop token is server-minted and could carry the recipe, but the
recipe is mutated in place under a stable session id (a /Sessions/Playing/Progress
audio switch restarts ffmpeg without re-minting the client's token) and a
third-party Jellyfin client cannot be driven to refresh a stale token, so the
node must reconstruct from a server-authoritative, node-reachable store.

Aligns the comments with docs/architecture/restart-resilient-playback.md §10.
Comment-only; no behavior change.

* refactor(playback): remove deny-lease revocation, defer to future PR

The deny-lease stream-revocation mechanism (the internal/streamauth
package, its silo:streamauth:<sid> Redis markers, the proxy Allowed()
enforcement, and the admin Stop/Terminate deny write) only ever enforced
on the offload-proxy topology and was a silent no-op on the integrated
single box and the dedicated transcode node. Rather than ship a partial
revocation feature that looks complete but isn't, remove it wholesale and
defer a uniform cross-topology revocation design to a dedicated follow-up.

Removed: internal/streamauth (package + tests); the LeaseDenier field,
StreamLeaseDenier interface, and denyStreamLease helper in playback.go;
the admin deny write; the router/main wiring; and the proxy verifyToken
Allowed() gate. The unified-reconstruct core (recipe-token,
LoadOrReconstructSession) is orthogonal and untouched.

Known limitation (now on every topology): admin Terminate and user Stop
tear down the live in-memory session and ffmpeg producer, but a still-valid
stream token can reconstruct the session until its 24h TTL expires. No
node-side byte-withholding ships in this PR.

docs/architecture/restart-resilient-playback.md is updated to mark the
revocation/deny-lease sections as deferred and to drop the overstated
"instant revocation on admin kill" claim.

* fix(playback): allow zero-caller bearer on transcode reconstruct

The authless HLS transcode delivery routes (master.m3u8 / segment) treat
the session UUID as the bearer credential, so a real request carries
requestUserID == 0. The live serve path already allows this, but
ReconstructSession hard-rejected a zero caller, so a request that worked
before a restart became SessionMissing -> 404 after the in-memory session
was gone, breaking the restart resilience these routes advertise.

Match the live-path contract in LoadOrReconstructSession: allow a zero
caller (UUID-as-bearer) and refuse only a non-zero caller that mismatches
the card owner. The reconstructed session is bound to card.UserID either
way. Adds TestReconstructSession_Ownership covering both cases.

* fix(jellycompat): re-persist recipe on local audio switch

A Jellyfin client switching audio on an integrated/local compat transcode
restarted live ffmpeg with the new track but did not re-persist
PlaybackSession.Recipe. The remote branch already re-persists via
startRemoteTranscode -> persistTranscodeRecipe. After a central restart,
reconstruct rebuilt ffmpeg from the stale Recipe.AudioTrackIndex, so the
integrated session resumed on the original audio track.

Persist the updated recipe (best-effort) after a successful Restart in the
local branch, mirroring the remote branch, so the durable
Recipe.AudioTrackIndex tracks live ffmpeg. Adds a regression test.

* fix(playback): strip stream token from proxied transcode-node URL

proxyToTranscodeNode appended the client's raw query string to the internal
transcode-node URL and logged that URL on transport failure. When a remote
transcode runs without a separate proxy node, that query carries
?st=<signed JWT> — a 24h bearer reconstruction descriptor exposing the
media path and recipe claims — placing the token into internal requests and
error logs.

Strip the "st" param before building targetURL, preserving any other query
params. The token is neither forwarded to the node nor present in the
logged URL. Header-forwarding of the token (so the node can reconstruct) is
a separate follow-up (#6).

* fix(playback): fail open on transient limit-provider error in reconstruct

During the reconstruct wave right after a restart (Postgres under peak
load), a transient limit-provider DB error was collapsed into a hard 404,
permanently stopping playback for a user within their limits. limitsForUser
wrapped any provider error, RegisterReconstructedWithLimits propagated it,
and ReconstructSession mapped every error to SessionMissing -> 404 -
indistinguishable from a genuine over-cap rejection.

Distinguish the two: tag provider errors with a new ErrLimitProviderUnavailable
sentinel and, during reconstruct, fail OPEN on a provider error (admit via
RegisterReconstructed + log a degraded warning) rather than refuse - mirroring
the reliability-first fail-open-on-dependency-error philosophy. A genuine
ErrTooManyStreams / ErrTooManyTranscodes over-cap still refuses. Adds tests
for both the fail-open and still-refused paths.

* fix(playback): forward stream token to transcode node as header

The dedicated transcode node's reconstruct path reads the stream token only
from the X-Silo-Stream-Token header, but proxyToTranscodeNode forwarded only
the node-API bearer token (and #5 now strips st from the URL). So when the
central API proxied to the node and the node self-restarted, it could not
reconstruct from the recipe-complete native token -> 404.

Capture st before stripping it from the URL, verify it at the API boundary
(streamtoken.Verify + SessionID match, mirroring the node's own check), and
forward it as X-Silo-Stream-Token. Best-effort: a missing/invalid token never
blocks the live proxy, and the token is still kept out of the forwarded URL
and logs.

* fix(playback): restart node ffmpeg on native remote audio switch

A native audio-track switch on an offloaded/remote transcode was a no-op at
the node yet returned 200 with a fresh URL: HandleChangeAudioTrack restarted
ffmpeg only when the API owned a LOCAL TranscodeSession, so for an offloaded
transcode the node kept serving the OLD audio (the node consults the token
only on a session miss). The replacement URL was also minted from identity-
only claims, so a later node restart 404'd.

For the offloaded transcode case (detected via session.TranscodeNodeURL),
POST a fresh /transcode/start to the node with the new AudioTrackIndex
(handleStart tears down and restarts ffmpeg) and mint the replacement proxy
URL from a full RecipeCard so reconstruct survives a node restart. The encode
recipe is derived from the durable session target fields plus the file,
mirroring HandleStartTranscode. A concrete SegmentDuration
(playback.DefaultSegmentDuration) is embedded rather than 0: the node's token
completeness gate treats SegmentDuration<=0 as incomplete and falls back to a
recipe store the native path never populates, which would 404 on a node
restart - the exact resilience this path provides. A failed node POST now
surfaces 502 rather than a false 200. Remux and non-offloaded (local)
transcode paths keep their prior identity-claim URLs unchanged.

Known limitation: Session does not persist the original SegmentDuration or
SubtitleTrackIndex/SubtitleBurnIn, so a remote audio switch resets subtitle
selection to none and assumes the default segment length; a client that
started with a non-default segment length will resegment on switch. Making
that state durable on the session is a follow-up.

* docs(playback): scrub stale deny-lease/revalidator comments

The deny-lease revocation mechanism and its "central revalidator" were removed
earlier in this branch, but four comments still described them as live
(transcode_manager.go, noderecipe/store.go, streamtoken/token.go,
proxy/server.go). Reword them to match the shipped behavior: ownership claims
are re-resolved at reconstruct, the noderecipe store shares Redis only with the
node-session tracker, and a sub-TTL hard cut depends on a node-side revocation
mechanism that is deferred to a future PR.

* fix(jellycompat): surface durable playback-session write failures

DurableCompatPlaybackStore.Update applied the in-memory mutation and then
swallowed every Postgres commit-failure path, returning nil. Callers that
promise restart resilience (persistTranscodeRecipe's recipe write, the
upstream-session binds in streams.go) were told the session was durably
persisted when only the cache held it, so a transient DB hiccup could leave
the next restart reloading a stale row (wrong audio track) or 404ing.

updateDB now returns the genuine DB round-trip error (begin/query/unmarshal/
marshal/exec/commit); Update propagates it while still applying the in-memory
mutation so live state stays correct. A nil pool and a genuinely absent/expired
row remain best-effort (return nil) — only real infrastructure failures
propagate, so existing rollback paths fire exactly when durability is lost.

Part of #174

* fix(playback): re-inject stream token into proxied transcode manifests

API-proxied remote transcode manifests dropped the reconstruct token from
their segment URLs, so playback died after a node or API restart. When a
remote transcode has no separate proxy node, the client loads its manifest via
the API-local path; proxyToTranscodeNode strips the signed token ("st") from
the forwarded URL (keeping it off node URLs and logs, forwarded only as the
X-Silo-Stream-Token header), and the node builds relative segment URIs from
that token-less query. The segment URLs the client received carried no token,
and the proxy only re-attached the header when an incoming segment request
already had "st" — which it never did — so a restart made those segments
non-reconstructable and they 404'd.

proxyToTranscodeNode now rewrites the manifest body at the boundary: every
segment and #EXT-X-MAP init URI gets the client-facing, API-verified token
re-appended (new playback.AppendManifestQueryParam helper), so the client's
later segment fetches carry "st" again and reconstruct after a restart. The
token still never reaches the node URL or its logs. Only 200 .m3u8 responses
are rewritten (Content-Length corrected); segments stream through untouched.

Part of #174

* fix(playback): preserve subtitle/cadence recipe across offloaded audio switch

Switching audio on a remote (offloaded) transcode with burned-in subtitles
silently dropped them, and reset a non-default segment cadence. The offloaded
audio-switch restart rebuilt the node start request from Session state, but
Session/SessionStreamState retained no subtitle or segment-duration state
(only the live local ts.Opts() and the RecipeCard did), so the branch
hard-coded SubtitleTrackIndex:-1, SubtitleBurnIn:false and
SegmentDuration:Default — signing that altered recipe into the replacement
stream token. An audio switch then changed bytes beyond audio selection, and
any later reconstruct kept the wrong no-subtitle/wrong-cadence recipe.

Persist the byte-affecting recipe on the session: SubtitleTrackIndex,
SubtitleBurnIn and SegmentDuration are added to Session/SessionStreamState,
populated at start (finalizeTranscodeStart) and on post-restart reconstruct
(ReconstructSession from the card), carried forward on every audio-switch
state update, and read back when rebuilding the offloaded node request and its
recipe card. The restart now reproduces the exact live stream. Also resolves
the M-4b non-default segment_duration reset.

Part of #174

* fix(playback): serialize transcode spawn paths with a per-session lock

Reconstruct was single-flighted only against other reconstructs, so a
restart-driven segment reconstruct racing a quality/seek/audio fresh start
could spawn two ffmpeg processes writing the same output directory at once —
segment corruption, partial-write closes, orphaned processes, and skewed
active-job accounting. The atomic register-after-spawn (GetOrRegister / the
reconstruct compare-on-register) prevented a map leak but not the concurrent
disk writers, because the losing path had already spawned. The dedicated
transcode node had the same split between handleStart and spawnReconstruct.

Add a refcounted per-session lifecycle lock to both TranscodeManager and the
node Server, held across "check existing -> spawn -> register":
- reconstruct (doReconstructTranscode / spawnReconstruct) re-checks under the
  lock and yields to any live session instead of spawning a duplicate;
- the native and jellycompat fresh-start paths take the lock around their
  spawn+register (the native path also closes any session a reconstruct rebuilt
  in the meantime so its fresh ffmpeg is the sole writer);
- the node handleStart holds it across teardown+spawn+register.
The refcount drops the map entry once no path holds/waits, keeping it bounded.
GetOrRegisterTranscodeSession is removed — the lock supersedes it and keeping a
register-after-spawn primitive would invite reintroducing the race.

Part of #174

* fix(playback): serialize restart re-spawn under the session lifecycle lock

TranscodeSession.Restart() releases s.mu across cancel -> wait-for-done ->
re-exec and spawns ffmpeg into opts.OutputDir without holding the per-session
lifecycle lock. LockSessionLifecycle's contract (fresh start, restart,
reconstruct) requires restart to hold it too, but all five callers invoked
Restart unlocked: native audio-switch and segment-recovery, compat
audio-switch and segment-recovery, and the transcode-node segment-recovery.

A restart racing another restart (audio-switch vs segment-recovery) or a
fresh-start/reconstruct could land two ffmpeg processes writing the same
segment directory -- mixed timelines, init.mp4/segment mismatch, and an
orphaned-but-still-writing ffmpeg -- the exact concurrent-writer corruption
the lifecycle lock exists to prevent.

Add RestartSessionLocked (TranscodeManager) and restartSessionLocked (node
Server) that hold LockSessionLifecycle only across the cancel->respawn
transition, re-check that the handle is still the live mapped session under
the lock, and return ErrSessionSuperseded rather than re-spawning a stale
handle. Route all five call sites through them. The lock is released before
callers wait on segments so recovery latency is unchanged.

Tests: gating (restart blocks until the lifecycle lock frees, then spawns),
concurrent-restart serialization, and superseded re-check on both the manager
(covers native + compat) and node lock owners.

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:23:14 -04:00
866392fecd feat(notifications): announce new audiobooks and ebooks on server channels (#260)
Audiobook and ebook libraries previously never entered the Recently
Added pipeline: availability detection only ran for TV/movie/mixed
libraries and release_events only knew episode/movie kinds, so server
channels (Discord/generic webhooks) could not announce new audiobooks
or ebooks.

Generalize the movie path into a flat-item-kind registry
(internal/notifications/item_kind.go) driving availability detection,
recording, channel toggles, payload rendering, test fixtures, and the
admin backfill seeder. New kinds share a kind-discriminated
item_availability table; movie_availability stays as-is. Channels gain
notify_new_audiobooks/notify_new_ebooks toggles (default on, additive
API fields) and embeds carry the author from item_people. Flood-safe by
construction: existing libraries seed silently on their first
post-upgrade full scan.

Extract internal/librarykind to replace the is*LibraryType helper
copies that had drifted across scanner, libraryingest, and metadata
(metadata's movie check silently included mixed; now spelled
explicitly).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:42:28 -04:00
57fbc1e0b4 perf(literaryworks): cut Postgres load on ebook↔audiobook auto-linking (#253)
* perf(literaryworks): cut Postgres load on ebook↔audiobook auto-linking

Rescans and candidate matching were driving high Postgres CPU on book
libraries.

- AutoLinkContent now checks literary_work_items with a cheap indexed
  lookup before GetMatchItem, so unchanged already-linked books skip the
  heavy triple-lateral query on every rescan.
- ListMatchCandidates is split into an indexable ID-selection phase
  (title / provider EXISTS / series EXISTS) and a hydration phase, so the
  per-row lateral aggregates run only for the LIMIT candidates kept rather
  than for every opposite-format book.
- Add a partial LOWER(title) index scoped to ebook/audiobook so the OR of
  title/provider/series filters can be driven entirely by indexes.

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

* fix(migrations): drop invalid leftover index before concurrent rebuild

An interrupted CREATE INDEX CONCURRENTLY (cancel/restart) can leave an
INVALID idx_media_items_books_title_lower; IF NOT EXISTS would then skip
the rebuild while Goose records success. Add the preflight invalid-index
drop used by the repo's other concurrent-index migrations.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:21:25 -04:00
9cae868a27 feat(downloads): offline sync for mobile — downloads v2 (#258)
* feat(downloads): offline sync for mobile (downloads v2)

Replace internal/download with a unified internal/downloads package and add
fully-offline download + watch-sync support for mobile clients, across five
independently-shippable phases:

- Phase 0: reshape the downloads table and the /downloads contract to be
  device- and format-aware; add GET /downloads/capability; extend
  DownloadConfig (default-off keys); update the web download hooks/components in
  lockstep. This is the one approved pre-lock exception to the additive-only
  /api/v1 rule (the web app is the only consumer and is updated together).
- Phase 1: managed device-library entries (create/list/PATCH/delete/serve),
  keyed on the X-Silo-Device-Id header.
- Phase 2: offline playback manifest plus artwork/subtitle proxy endpoints that
  strip every presigned URL (inline thumbhashes + authenticated proxies).
- Phase 3: prepare-to-file (remux + transcode-to-single-file) as a durable,
  leased artifact queue with startup recovery, hosted on the task manager;
  playback.PrepareFile emits one +faststart MP4. Adds the admin transcode
  toggle and per-artifact LRU cleanup.
- Phase 4: offline progress reconciliation -- a clamped event_at LWW key plus a
  server-assigned synced_seq cursor on watch_progress; an optional clamped
  updated_at on POST /sync/progress and an opaque ?since= cursor on
  GET /progress (additive; existing callers unaffected).

Security & reliability invariants, each with an acceptance test:
1. Server-owned sync ordering: ?since= delta delivery is driven only by the
   server-assigned synced_seq; the client clock is bounded (event_at, clamped
   to now+skew) and used only for last-write-wins on the caller's own profile.
2. Full profile+device authorization on every managed endpoint, with a
   per-profile content/library access re-check before serving any bytes/assets.
3. Durable artifact recovery: a transactionally-claimed (FOR UPDATE SKIP
   LOCKED), lease-heartbeat, attempt-counted queue with a startup sweep, so no
   crash strands a download in preparing and concurrent workers never
   double-encode.

Migrations are timestamped Goose files: reshape downloads (device/format);
download_artifacts (durable queue); watch_progress event_at/synced_seq.

DB-backed acceptance tests skip without SILO_TEST_DATABASE_URL and run in CI;
the invariant-1 progress test also runs against the real SQLite backend locally.

Client repos (silo-android, silo-apple) consume the reshaped /downloads/*
contract and the updated_at/?since= progress fields and require coordinated
follow-up.

Implements the maintainer-approved v1 capability proposal for offline sync
(downloads v2).

AI-use disclosure: implemented by Claude (Claude Code) from the approved design
doc under docs/superpowers/specs, with human review.

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

* feat(downloads): series & season downloads + client-pull monitoring

Build season downloads and a "monitor a series" capability on top of the
downloads v2 (offline sync for mobile) work.

Season downloads:
- POST /downloads accepts season_number (with series:true) to download one
  season. CreateSeries/CreateSeason share one body via a listEpisodes closure
  and register managed entries under a shared batch_id (original-only). Episode
  files are resolved in a single batched query.

Series monitoring (auto-download), client-driven:
- New device-scoped download_subscriptions table with a Sonarr-style mode
  (all | future | latest_season | specific_seasons), a client-enforced
  delete_watched flag, and a max_storage_bytes cap. The server never deletes
  on-device files; retention and the hard cap are the client's, the server
  only soft-gates registration.
- The client calls POST /downloads/subscriptions/sync on open / background
  refresh; the server registers the in-scope, not-yet-downloaded episodes
  (idempotent via the managed-entry unique index) and the device pulls them on
  its own schedule. No background worker and no dependency on the notifications
  subsystem. latest_season follows new seasons (>= subscribe-time season);
  future excludes the back catalog via air date.
- Subscription CRUD + sync are profile+device authorized (device id from the
  X-Silo-Device-Id header only) with a per-request content-access re-check. The
  capability endpoint advertises season_download / series_monitoring /
  monitoring_modes.

Also lands the downloads-v2 work already present in the tree: durable artifact
(remux/transcode) preparation and offline watch-progress reconciliation, plus
the design-spec updates.

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

* WIP: epitaxy pre-switch from feat/downloads-v2-offline-sync

* test(downloads): fix deterministic ID collision in reconcile test

Artifact IDs are time-sortable, so two artifacts created in the same
moment share their first 8 chars; combined with a captured timestamp the
two preparing-download IDs collided on downloads_pkey. Use the full
artifact ID, which is unique per row.

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

* fix(downloads): support sqlite userdb backend for managed downloads

With the sqlite userdb backend, profiles live only in per-user SQLite
stores and public.user_profiles stays empty, so user_devices'
profile FK made every managed create/subscription/offline-sync request
fail with an FK violation. Drop the FK (shared Postgres tables must not
FK profile tables — same rule as notifications) and replace the lost
cascade with an app-level purge on profile deletion, wired through
ProfileHandler for both backends. DB-backed regression tests cover the
no-Postgres-profile-row path and the purge cascade.

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

* fix(downloads): dispatch encode kick asynchronously

triggerDrain invoked the kick inline, and the kick (taskmanager RunTask)
executes the encode task on the caller's goroutine — so a POST
/api/v1/downloads with a bitrate quality blocked the HTTP request on the
entire queue drain, ffmpeg encodes included, delaying the 202 by minutes
on an idle queue. Dispatch the kick on a goroutine; the task manager
already serializes concurrent runs.

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

* fix(downloads): enforce per-user quota on the encode pipeline

Two gaps let a user bypass MaxConcurrentPerUser entirely for prepared
downloads: artifact-backed rows are created in 'preparing' (never
'queued'/'downloading'), which CountActiveByUser didn't count, and
createArtifactDownload enqueued the encode job before limiter.Check, so
even a 429-rejected request left a job the worker would transcode.
Count 'preparing' as active and check the limiter before Ensure; managed
replacements stay quota-exempt since they don't add a row.

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

* fix(downloads): protect ephemeral artifact links from LRU eviction

HasActiveLink only counted managed (device_id IS NOT NULL) rows, so
under a byte budget Cleanup could delete an artifact still referenced by
a ready-but-unfetched ephemeral web download — permanently 404ing a row
the API kept listing as ready (the artifact row is gone, so recovery
can't re-queue it). Any non-terminal link now protects the artifact;
only artifacts whose links are all cancelled/failed/revoked are
evictable.

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

* fix(downloads): batch manifests skip bad entries instead of failing whole batch

One deleted or access-filtered episode made GET
/downloads/batches/{id}/manifests 404 for the entire season, so a
client could no longer fetch manifests for the still-valid entries.
Report unbuildable entries in a skipped[] array (revoked | not_found |
error) alongside the delivered manifests, mirroring the create path's
skip idiom. Also cut the batch cost: the shared series detail is
resolved once per batch instead of once per episode, and buildSubtitles
reuses the already-loaded media file instead of re-querying it per
manifest.

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

* fix(migrations): wrap DO block in StatementBegin/End markers

Under NO TRANSACTION goose splits statements on semicolons, so the
dollar-quoted DO block failed every fresh install with 'unterminated
dollar-quoted string' (SQLSTATE 42601). Already-applied databases are
unaffected. Same fix is being applied to main; identical content merges
cleanly.

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

* fix(api): allow season 0 (Specials) in season downloads

season_number was a plain int dispatched with '> 0', so requesting the
Specials season was indistinguishable from omitting the field and
silently broadened to a full-series download. Dispatch on pointer
presence, treat 0 as the Specials season, and reject negatives with 400.

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

* fix(downloads): capability quality_presets is never JSON null

PresetsFor returned a nil slice when downloads are disabled or the user
lacks the permission, and Capability's []string{} initialization was
immediately overwritten by it — so GET /downloads/capability serialized
"quality_presets": null where the contract documents an array.
Normalize at the source so every caller inherits the guarantee.

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

* fix(downloads): subscription sync correctness + batched registration

Three subscription fixes:

- A paused subscription no longer syncs: PATCHing scope (or pausing and
  changing scope in one request) registered episodes for a monitor the
  user had just stopped, inconsistently with SyncSubscriptions' guard.
- SubModeFuture compares calendar days (UTC): air_date is date-only, so
  the strict instant comparison permanently excluded episodes airing the
  same day the user subscribed; episodes with no air date now fall back
  to their ingest time instead of never registering.
- Registration is one batched fetch (GetManagedEntriesByKeys) plus one
  batched INSERT ... ON CONFLICT DO NOTHING RETURNING
  (CreateManagedEntriesBatch) instead of a SELECT+INSERT per episode —
  a 300-episode series cost ~600 sequential round trips per request and
  every no-op sync re-walked the full set. RETURNING yields exactly the
  new rows, so the sync response's 'registered' count now honestly
  reports 0 in the steady state instead of the full in-scope count on
  every app open. The now-unused InsertManagedEntryIfAbsent is removed.

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

* fix(userstore): stamp triggers own the event_at LWW key

MarkProgressBatch (jellycompat series mark-played) advanced updated_at
but never event_at, and both stamp triggers only defaulted event_at when
NULL — so a queued offline event with a client time between the row's
old event_at and the mark could win SetProgressIfNewer and resurrect a
stale resume position that then re-synced to every device.

Make the triggers authoritative instead of adding a tenth hand-written
SET clause: whenever an UPDATE changes updated_at without explicitly
changing event_at, the trigger advances the LWW key; writes that do set
event_at (offline sync's clamped client event time) keep their value.
Postgres gets a CREATE OR REPLACE migration; SQLite gets a v12 userdb
migration that drops and reinstalls the trigger bodies (CREATE TRIGGER
IF NOT EXISTS never replaces). Conformance tests cover both batch paths,
the preserved-client-time invariant, and the v11→v12 upgrade.

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

* fix(downloads): lifecycle hygiene — squash migrations, dead status, stale-row sweeps

Migrations: fold the 20260621 corrective migration back into the base
Downloads V2 migrations (its columns/constraints already exist there)
and fix the reshape Down, which re-added the narrow status CHECK without
collapsing managed-lifecycle rows first — rollback aborted on any DB
with preparing/ready/revoked rows; validated against a live row. Branch
databases that applied the corrective migration need its version row
removed: DELETE FROM goose_db_version WHERE version_id = 20260621020459.

Code: drop the dead 'registered' status (nothing ever wrote it; the
lifecycle is preparing -> ready; 'revoked' stays reserved for the
planned admin revoke flow) along with unused KindDirect and
ErrInvalidFormat.

Sweeps: Cleanup now runs an age-based hygiene pass independent of the
byte budget — cold terminally-failed artifacts (with .part leftovers),
orphaned ready artifacts no download row references, and ephemeral web
rows older than their convenience-record lifetime (also unpinning their
artifacts and bounding GET /downloads growth). The byte budget remains
the disk quota per the limits & restrictions design.

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

* docs(downloads): sync API doc with v2 fixes; HEAD on file route; Android handoff

Document the contract changes from the review fixes: batch-manifest
skipped[] shape, honest subscription 'registered' semantics, season 0 =
Specials, always-array quality_presets, bytes_sent actual behavior,
ephemeral 7-day retention, header-pairing requirement, progress-delta
deletion caveat, and the ready/failed push event schema (new §9.4).
Add an Android client handoff section (§11) mirroring the Apple one,
register HEAD on /downloads/{id}/file for download stacks that probe
before ranged GETs, and add season_number to the web create-request
type. Flag the /direct-download session-token-in-URL tradeoff; a
short-lived download-scoped URL is a follow-up.

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

* refactor: consolidate download/progress helpers, prune dead code, gate sweeps

Behavior-preserving consolidation from the Downloads V2 review:

- appendVideoFilterArgs: one home for the burn-in/hwaccel -vf selection,
  shared by the HLS builder and the single-file prepare builder (the
  drift pattern that already bit tone-mapping once).
- userstore.ResolveProgressState: one home for the min-resume/watched
  threshold rule, replacing five identical copies across both store
  backends and the offline-sync ingest.
- Download file selection ranks resolutions via access.CompareQuality
  (adds 4320p, agrees with playback) instead of a private switch.
- writeSubtitle uses the shared subtitles.SubtitleContentType mapping.
- config.DefaultTranscodeDir replaces three '/tmp/silo-transcode'
  literals.
- Read-side quality/revision defaulting helpers removed: insertArgs plus
  the NOT NULL/CHECK schema already guarantee the invariant.
- Dead code removed: Repository.ListByUser, SubscriptionRepository.
  ListActiveBySeries, and the stale auto-register-worker comments (the
  design is client-pull; no worker exists).
- Redundant left-prefix indexes dropped from the base migrations (their
  unique indexes serve the same prefixes).
- recover()'s disk-presence sweep and the stale-row hygiene sweep run on
  startup then hourly instead of every 30s tick (both are O(cache
  size)).
- gofmt/prettier fixes for pre-existing drift in handlers/playback.go
  and pages/Profiles.tsx.

Deferred (noted for follow-ups): quality-ladder preset table collides
with the drafted download limits & restrictions design, which specifies
its own ladder helper; Download-literal construction consolidation and
the managed-identity value object remain open.

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

* docs(downloads): draft download limits & restrictions design

Design input for the follow-up v1 capability proposal (quality ceiling,
batch size cap, per-user quantity/bandwidth overrides). Committed with
downloads v2 because the remediation work explicitly defers the quality
ladder refactor and revocation wiring to this spec.

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

* fix(progress): reject malformed updated_at; clamp negative progress inputs

Review findings on #258:

- A malformed (non-RFC3339) updated_at in POST /sync/progress previously
  parsed to the zero time, which clampEventAt treated as "now" — letting a
  stale offline event win LWW as a fresh server-time write. The item is now
  rejected with a per-item error instead.
- ResolveProgressState now clamps negative position/duration before
  classification so no backend can persist negative progress through
  UpdateProgress/SetProgress.
- The online-write event_at invariant test is table-driven over both
  SetProgress and UpdateProgress, which share the same contract.

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

* fix(downloads): close review gaps — permission gates, file-access recheck, artifact-true manifests

Review findings on #258:

- UpdateSubscription now applies the same feature/DownloadAllowed gate as
  CreateSubscription and SyncSubscriptions; a PATCH could previously
  re-activate or widen a monitor and register managed rows after an admin
  disabled downloads or revoked the user.
- Serving download bytes (managed and ephemeral) and /direct-download now
  mirror playback's per-file authorization via catalog.FileAllowedByAccess:
  library scope and the profile's max playback quality are re-checked at
  serve time, with artifact-backed rows checked against the artifact's
  resolution (a 720p transcode of a 4K source stays servable under a 1080p
  ceiling).
- Offline manifests for remux/transcode entries now describe the prepared
  artifact (container, codecs, resolution, single selected audio track)
  instead of the catalog source file the client never receives.
- ArtifactRepository.Requeue reports ErrNotFound when the row was
  concurrently swept; ArtifactManager.Ensure recreates the job in that case
  instead of linking downloads to a dead artifact id.
- "No downloadable episodes" is a sentinel (mapped to 404
  no_downloadable_episodes) rather than a bare error that surfaced as 500.
- Subscription season_numbers are bounds-checked (0–9999) before the int32
  narrowing in the repo could silently wrap them.
- HandlePatchDownload reuses requireManaged instead of hand-rolling the
  same managed-identity checks.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:05:36 -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
443445f0fe fix(catalog): make media_items scalar columns NOT NULL to stop NULL-scan crashes (#228)
media_items had many columns the Go model (models.MediaItem) declares as
non-pointer string/int fields, but migration 001 left them nullable. Five
scanners (catalog item_repo, catalog browse, jellycompat, sections, and
the catalog API handler) read these straight into the non-pointer fields,
so a NULL row panics with "cannot scan NULL into *string" (or *int).
item_repo papers over a subset (poster/backdrop/logo/metadata paths) with
COALESCE in its SELECT, but the other four scanners read the same columns
raw and crash; sort_title/original_title/etc. are not coalesced anywhere.

No writer stores a meaningful NULL (every insert/upsert passes the Go
field, '' or 0 at worst) and all sort/filter SQL already collapses NULL
and '' (e.g. COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path
IS NULL OR poster_path = ''"). Enforce the invariant the code already
assumes at the schema level rather than scattering COALESCE across every
current and future scanner. Mirrors what later migrations already did for
original_language, show_status, default_metadata_language, and the
*_source_path columns (all NOT NULL DEFAULT '').

- Migration: backfill existing NULLs, then NOT NULL DEFAULT '' on 15 text
  columns (sort_title, original_title, content_rating, overview, tagline,
  imdb_id/tmdb_id/tvdb_id, poster_path, poster_thumbhash, backdrop_path,
  backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag) and
  NOT NULL DEFAULT 0 on year/runtime.
- item_repo: SetLocalPoster and UpdateArtworkIfSourceMatches now store ''
  for empty thumbhashes (was NULLIF($,'')) — the only deliberate NULL
  writers — matching the upsert path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:01 -04:00
02e62767a1 feat(watchsync): sync watchlists with Trakt/Simkl/MDBList (#227)
* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList

Extend the watch-providers feature to sync a user's watchlist, generalizing
the existing favorites pipeline rather than duplicating it.

What changed
- Generalize the favorites sync into one ListKind-parameterized pipeline
  (internal/watchsync/lists.go) driving both favorites and watchlist; the
  per-favorites service methods are replaced by kind-generic ones. The shadow
  table watch_provider_favorite_items becomes watch_provider_list_items with a
  list_kind discriminator.
- Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from
  favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from
  favorites to watchlist (its only list is a watchlist) — its capabilities now
  report import_favorites=false / import_watchlist=true, and the migration
  re-binds existing MDBList connections.
- Auto-remove watched items from the watchlist: a standalone, default-on
  profile preference (user_profiles.remove_watched_from_watchlist) removes a
  movie when watched and a series once every episode is watched. Implemented as
  watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the
  manual mark-watched, playback-stop, and jellycompat mark-played paths.
- Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle
  mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index;
  ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist
  and the catalog watchlist view inherit it.
- Real-time + scheduled: local add/remove pushes to connected providers
  immediately (removals gated by the opt-in removals toggle); the hourly job is
  the inbound/import + retry/reconcile path.
- Web: watch-provider settings gain watchlist import/export/removals and
  "mirror watchlist order" toggles plus watchlist sync stats.

Why
- The favorites and watchlist pipelines are ~90% identical; generalizing keeps
  one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning.

API/compat
- All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and
  the web types are additive (Silo v1 additive-only rule). No existing field is
  renamed, removed, or retyped.

Risks / follow-up
- MDBList capability flip is intentional and client-visible: silo-android /
  silo-apple may need to surface MDBList under the watchlist (not favorites) UI.
- MDBList existing users: their MDBList list previously mirrored Silo favorites
  and now mirrors Silo watchlist; the first post-migration sync is a union
  (removals default off), so nothing is destructively purged.
- Order mirroring reflects the order MDBList returns from /watchlist/items
  (couldn't confirm against their docs — Cloudflare-blocked); if it ever
  diverges from the UI sort, a sort param is the small follow-up.

Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider +
service tests updated. go build, go test (affected pkgs), migrate-validate,
verify-local-paths, web prettier/eslint/tsc all pass.

AI-use disclosure: implemented with Claude Code (Claude Opus 4.8).

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

* fix(watchsync): update list shadow table references

* fix(watchsync): address review — retry/progress + error propagation

Addresses CodeRabbit review on #227:
- maintainer: propagate transient catalog lookup errors instead of silently
  treating every items.GetByID failure as "maybe an episode".
- exportList: mark every queued item not confirmed sent (not_found, failed, or
  omitted) so the pending loop always advances; the next run's upsert clears the
  error and re-attempts, so transient failures still retry.
- removePendingListItems + realtime removal: treat Sent and NotFound as
  reconciled; leave true failures pending (no last_error, which would strand
  them from the removal query) so the scheduled run retries, using in-memory
  dedupe to terminate the loop.
- exportLocalListItems: send the normalized items (with computed
  ProviderItemKey), not the original event slice.
- UpdateConnection: clear mirrored watchlist order before persisting the disable
  and propagate failures, so a failed clear can't report "disabled" while
  sort_index ordering is still active.
- web: include favorite + watchlist removal counts in the exported "sent" total.
- test: align serviceFakeRepo list-state with Postgres (clear last_error on
  successful transitions); add maintainer error-propagation test.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:05:53 -04:00
Quick 7f67bb80ad Fix semantic-disabled Meilisearch settings
- Omit Meilisearch embedders unless semantic search is enabled
- Default semantic ratio setting to 0.50
2026-06-26 12:09:45 -04:00
QuickandClaude Opus 4.8 927d7764a4 feat(search): model-filtered embed-eligible coverage counts
Establish how semantic vector "coverage" is counted: current-model
embeddings over embed-eligible items, per media type, from Postgres.

- Move the embed-eligibility predicate to a single source of truth in
  embeddingvectors.ItemEligibilityWhereClause; recommendations now
  delegates to it (output byte-identical).
- Add catalogSemanticCoverageByType (per-type eligible/vectorized) and
  define the coverageQuerier interface in catalog. Both numerator and
  denominator apply the eligibility predicate, so vectorized never
  exceeds eligible (C1 guarantee) even with a stale embedding on a
  now-unmatched item.
- countCatalogSearchVectorDocuments now takes a coverageQuerier and a
  model, applies the eligibility + model filter, and is the per-type
  numerator summed across types. Update all four callers (pass "").
- Add idx_media_item_embeddings_model (CONCURRENTLY, self-healing
  guard) so the model filter does not scan the embeddings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:43:23 -04:00
Quick 07f2dd5a8f fix(admin): bound autoscan activity backlog 2026-06-25 20:50:45 -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
Quick 5c0027b54b fix(metadata): dedupe availability during re-id 2026-06-25 13:29:30 -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
Quick 227986094b Propagate playback client metadata through session sync 2026-06-23 14:26:14 -04:00
rxwatcher 729468be30 fix: wrap episodes season index migration block 2026-06-19 06:13:26 +02:00
QuickandGitHub 7e532ea982 [codex] fix season page loading (#178)
* fix(catalog): speed up season page loads

* fix: address season loading review feedback
2026-06-18 14:18:35 -04:00
14ffc91dfb [codex] Expand provider image cache queue (#176)
* feat(metadata): expand provider image cache queue

* fix(metadata): harden provider image cache queue

Addresses bug-review feedback from Codex/CodeRabbit on the metadata image
cache pipeline. All findings validated against the code before fixing;
false positives (rows/connection deadlock, PhotoSourcePath merge coupling)
were confirmed non-issues and left unchanged.

- Honor metadata.cache_images for the background processor. The
  cache_metadata_images task was registered whenever S3 was configured,
  so merely enabling object storage downloaded the entire provider-artwork
  catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled,
  gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from
  cfg.Metadata.CacheImages in main.go.
- Guard terminal job updates with lease ownership. EnqueueBatch can
  repurpose a running row with a new source; MarkSucceeded/MarkFailed
  keyed on id alone let a stale worker finalize the replacement job and
  drop the new artwork. Thread locked_by through and add
  status='running' AND locked_by=$n guards.
- Avoid uploading stale jobs onto the live artwork key. Verify the
  target still references the job's source (CurrentTargetSourcePath)
  before CacheImage, so a job whose source an admin/refresh already
  replaced cannot overwrite the deterministic storage object.
- COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A
  NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and
  aborted the whole cache run; matches the existing item_repo pattern.
- Stop re-downloading the catalog every 30 days. Discovery now skips
  targets whose *_path is already a cached relative path, making the
  cached row the durable dedup marker instead of the prunable job row.
- Decouple catalog sweeps from queue draining. RunOnce no longer runs
  discovery per batch; RunUntilIdle sweeps only when the queue drains and
  throttles full sweeps to every 15m, so idle installs stop full-scanning
  every entity table each minute.
- Requeue claimed-but-unstarted jobs on cancellation. Acquire the
  semaphore before spawning workers and RequeueClaimed any jobs not yet
  started, instead of leaving them locked until the 15m lease expires.
- Skip the backoff sleep after the final upload attempt in
  putObjectWithRetry (saves ~1.5s on permanent failures).
- Add the s3/file/local/upload/generated exclusion to the seasons and
  episodes backfill in migration 20260617184537 for consistency with the
  later migration (the bad backfill was inert downstream, but the
  asymmetry is removed).

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:58 -04:00
Quick 562ae635d7 feat: improve audiobook groups and notification refresh 2026-06-17 13:48:57 -04:00
Quick 20e0dd7fe2 Fix Jellyfin parent browsing and release dedupe
- List seasons or episodes correctly for Jellyfin parent item queries
- Deduplicate episode availability by logical episode identity
- Improve audiobook title fallbacks and existing item updates
2026-06-17 13:12:18 -04:00
9b111649f3 perf+fix(audiobooks): detail page, browse, sessions & scanner (#169)
* perf(catalog): fix audiobook detail N+1 + slow people facets

Audiobook detail pages were slow in proportion to track count (up to 433
files/book). Root causes, found by EXPLAIN ANALYZE on the live DB:

- effectiveAudioSelection ran 3-4 user-store queries (profile, audio pref,
  library pref) per file inside buildPlaybackInfo's loop, though the results
  are invariant across a request. Introduce a request-scoped audioPrefResolver
  that memoizes the store lookups (library prefs keyed by folder); a 400-file
  audiobook now issues each query once instead of per file. Selection logic is
  unchanged (audioPreference returns a copy so the original-language sentinel
  is still resolved per file).
- buildAudiobookExtension ran its four independent related-content queries
  serially; run them concurrently so latency is the slowest, not the sum.
- author/narrator browse facets did a full people-table scan; add a
  (kind, content_id, person_id) index so the facet resolves from an index-only
  scan of just that kind's credits (~112ms -> ~49ms on the live library).

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

* perf(catalog): cache audiobook author/narrator group browse

The Authors/Narrators audiobook pages were slow on cold load and slow again
after a hard refresh (fast only while the React Query client cache was warm).

Root cause (EXPLAIN ANALYZE on live, 31K-audiobook library): the grouped
browse query is ~234ms/page, there are ~13K distinct authors, and the client
pages through the entire list on every load (sequential 500-row requests). With
no server-side cache each of the ~20 pages re-ran the full aggregation
(COUNT(*) OVER() forces it), so a cold load was ~20x234ms. The client's 60s
staleTime was the only thing making a warm revisit fast; a refresh wiped it.

Fix: AudiobookGroupsCache caches the full sorted group list per (library,
group_by, sort, viewer) for 60s (matching the client staleTime, so no extra
staleness) and serves every page as an in-memory slice — one aggregation per
window instead of one per page, and a refresh is a cache hit. Also raise the
client page size 500->2000 so fewer sequential round-trips are needed now that
a larger page is a cheap slice.

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

* perf(settings): throttle per-request device last_seen upserts

Device-setting reads (HandleGetDeviceSetting, HandleGetEffectiveSettings,
HandleGetEffectiveSubtitleAppearance) each registered the request's device — an
INSERT ... ON CONFLICT upsert of last_seen_at on a single per-device row. A page
that fetches many settings fired hundreds of these concurrently; they serialized
on that row's lock (observed 100-237ms each, ~250 per page load in the slow
query log), taxing every settings fetch.

Throttle device registration to one upsert per (profile, device) per 5 minutes
via an in-process TTL cache, marking the device seen before the upsert so a
concurrent burst collapses to a single write. last_seen_at stays fresh to within
the window. Reads no longer issue a contended write on the hot path.

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

* perf+fix(audiobooks): probe-repair, resume position, cache storm, groups reveal, hot-row + stats index

From the full audiobook code review (EXPLAIN + slow-query trace on live):

- #1 (P0, detail-page killer): NeedsCriticalProbeRepair required video codec/
  resolution/tracks, which audio-only files never have, so PlaybackProbeEnsurer
  re-ran ffprobe per file on every detail/watch load (up to N serial spawns for
  an N-track book) and never converged. Gate video-field checks on the file
  actually having a video stream. TDD.
- #3 (P0): abs session-sync rewound the resume cursor — UpdateProgressPosition
  did an unconditional SET with no monotonic guard, ignored its error, and
  no-op'd when no row existed (first-listen resume lost). Now a finish-preserving
  GREATEST upsert; caller logs failures.
- #4 (P0 perf): progress reports fired every ~10s invalidated all of
  catalogKeys.all → refetched every active browse/detail query incl the 13k
  audiobook group lists. Scope invalidation to the reported item's detail.
- #6 (P1 perf): Authors/Narrators page rendered all ~13k groups + cover images
  at once (main-thread freeze). Incremental reveal: render a capped window, grow
  on scroll via IntersectionObserver.
- #10: throttle abs TouchToken last_seen upsert (one per token per 5min) — same
  hot-row contention class as the device fix.
- #8: index abs_playback_sessions (user_id, profile_id, started_at) for the
  listening-stats aggregations.

Deferred (need contract/validation): listening-time idempotency (client delta-vs-
cumulative), scanner deleted-file reconcile, abs session retention job, abs list-
handler batch fetch, scanner-output P2s (need re-backfill).

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

* perf(audiobooks): batch-fetch abs list/shelf handlers (kill N+1)

handleSimilarItems, handleItemsInProgress, and handleGetMyProgress called
MediaStore.GetAudiobookByID once per row — up to ~500 single fetches (each a
few queries) on app open. Add GetAudiobooksByIDs (one access-scoped fetch +
people/series hydrated once for the whole set) and look results up from the
returned map, preserving order. Underlying primitives (GetByIDsWithAccess,
hydratePeople/Series) were already batch-capable.

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

* fix(audiobooks): reconcile deleted files on scan + prune session history

#5: ScanAudiobookFolder only ever upserted — deleted/renamed books leaked
media_items/media_files/memberships forever. Mirror the ebook reconcile: collect
seenPaths during the walk, MarkMissing files no longer on disk, then
reconcileLibraryMemberships. Safety mirrors ebooks/video: an inaccessible root
(unmounted source) is skipped entirely, and a walk that saw zero files while the
DB has rows only reconciles after operator cleanup confirmation
(ebookEmptyCleanupAllowed) — so a flapping mount can't wipe the catalog. Soft
mark only; the existing grace-period purge hard-deletes later. Reconcile runs
only on a fully-completed (non-cancelled) scan. (#9 coarse case already handled:
audiobookFolderShouldSkip skips unchanged folders; per-file reuse deferred.)

#8-retention: abs_playback_sessions grew unbounded (one row per play-start, never
deleted) and fed every listening-stats scan. Add an hourly sweep in
SessionCleaner: close abandoned open sessions (no /close, stopped syncing >24h)
and delete closed sessions older than 90 days. Mirrors the recommendation_cache /
missing-files prune pattern.

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

* fix(audiobooks): address max-effort code-review findings

From /code-review max on the pre-PR diff:
- DATA RACE (P0): SessionCleaner.lastABSSessionPrune is read+written by both the
  15s ticker goroutine and the shutdown-path CleanStale call (main.go defers
  Stop() to after that call). Guard the prune-due gate with a mutex. (CleanStale
  was stateless before this branch, so concurrent calls were previously safe.)
- ScanAudiobookFolder hardcoded fullScan=true into the empty-walk cleanup guard,
  but it's also called from ScanSubtree (incremental scans). An empty subtree
  scan would wrongly consume the operator's one-shot empty-cleanup allowance and
  warn. Thread a real fullScan flag (true from ScanFolder, false from the two
  subtree call sites), mirroring the ebook path.
- Revert UpdateProgressPosition to UPDATE-only (drop the INSERT-on-missing):
  keep the monotonic GREATEST + finish guard that fixes the resume rewind, but
  restore the no-op-on-missing contract so a stray sync tick can't resurrect
  just-cleared progress or create a zero-duration continue-listening row.
- Clamp the audiobook-groups handler limit (paging moved into the cache, leaving
  the old 500/page bound stranded); also gofmt the Scanner struct.

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

* fix(audiobooks): address review feedback for scanner and stats

* fix(audiobooks): address review feedback

* fix(audiobooks): retry failed session prune

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 09:51:43 -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
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00
bf54f040f9 fix(audiobooks): extract scan covers and harden dedupe (#103)
* fix(audiobooks): extract scan covers and harden dedupe

* fix(audiobooks): address scan cover review

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 17:58:01 -04:00
13c5e0ba2f feat(catalog): deterministic cross-server content_id (#155)
* feat(catalog): deterministic cross-server content_id

Replace per-server Sonyflake content_id with a structured natural key
derived from provider IDs (movie:tmdb:…, series:tvdb:…, episode:…,
local:… fallback), so two servers holding the same title share one
anchor for artwork, watch history, progress, favorites and ratings.

- internal/contentid: derivation core, SeriesIDFromContentID transform,
  frozen precedence, SchemeVersion=1, embedded-series-anchor invariant.
- internal/metadata/service.go: deterministic id at every mint site.
- internal/catalog/history_source.go: resolve show via string transform
  for anchored episode ids; skip the episodes_pkey probe.
- migrations/sql/20260612130000: collision-safe value remap across the
  65-column reference graph + COLLATE "C", FK/trigger handling, audit
  map, working down.

Benchmarked against an exact-cardinality copy of cprod-postgres
(1.93M episodes, 775k history rows): 2.57x faster history page, 1.7x
throughput at 100 concurrent users, 2.7x cheaper per content_id probe.

* feat(catalog): re-ID untagged items to deterministic content_id at first match

Untagged libraries get a path-derived local: content_id at scan time and
only learn their provider IDs later, when the match worker confirms a
result. Previously that id was never folded back in, so untagged-then-matched
items kept a per-server local: placeholder forever and never converged across
servers (re-ID was deferred to a migration rerun).

mergeAndPersist now promotes a local: skeleton to its deterministic
provider-anchored id at the moment of first confirmed match, via a single
new gate (canonicalizeLocalContentID):

  - target id already taken  -> merge onto it (existing rebind machinery)
  - target id free           -> rename in place

The rename is a single SQL function (silo_rename_content_id); FK children
follow via ON UPDATE CASCADE added to the content_id family, so a fresh
skeleton moves a handful of rows rather than the full-table remap the bulk
migration does. The guard is one IsLocal prefix check, so tagged content and
all refreshes pay nothing, and the move is self-healing under retry.

Verified: gofmt/vet/build clean; migrate-validate passes; migration applies
on the real schema (up/down/up), FKs gain ON UPDATE CASCADE while keeping
ON DELETE; functional test confirms series PK move + series_id cascade +
provider-id sweep, and movie rename.

Follow-ups (noted in docs): recomposeSeriesChildIDs for a series that
accumulated episodes before matching; a lockstep test for the soft-ref list.

* fix(catalog): harden content_id parsing and merge per review

Address review feedback on the deterministic content_id work:

- history_source.go: gate the anchored-episode display-id transform on the
  full five-part episode shape (split_part parts 2-5 non-empty), not just the
  'episode:' prefix, so a malformed id can't transform to 'series:broken:' and
  vanish at the media_items join. Shared anchoredEpisodePredicate drives both
  the null-poisoned join key and the series-recovery expression.
- contentid.go: unexport the provider-precedence slices so no package can
  mutate the frozen SchemeVersion ordering at runtime.
- contentid.go: add parseAnchored to validate the exact per-kind arity and
  numeric season/episode suffixes; SeriesIDFromContentID and IsProviderAnchored
  now fail closed on truncated/malformed ids (e.g. "episode:tvdb:296762").
- canonicalize.go: distinguish catalog.ErrItemNotFound from transient lookup
  errors (a real error no longer masquerades as "target free"), and allow a
  matched local source to be consolidated onto the canonical row instead of
  orphaning a duplicate.

* refactor(contentid): URL-safe "-" separator in content_id

Use "-" instead of ":" to join content_id components
(movie-tmdb-228064, episode-tvdb-296762-1-5, local-<hex>). "-" is an RFC 3986
unreserved character, so a content_id is URL-safe verbatim: encodeURIComponent
is a no-op and the id is its own tidy path segment (/item/series-tvdb-296762)
with no %3A escaping. The stored value equals the URL value, so there is no
encode/decode boundary and an operator can grep the id straight out of a URL or
log. Every component is [a-z0-9]+ (or "tt"+digits), so "-" is unambiguous.

Pre-release format finalization: this branch is unmerged, so no deployed data
carries ":" ids — the migration mints the "-" form fresh and no re-migration is
needed. Still SchemeVersion 1.

- contentid.go: single `sep` constant drives construction and parsing so the two
  can never drift; all constructors/parsers and doc examples updated.
- history_source.go: split_part transform and the anchored-episode predicate use
  '-'; kept in lockstep with the package via a code comment.
- 20260612130000_deterministic_content_id.sql: derivation and season/episode
  composition emit '-'; LIKE filters match 'series-%'.
- docs/architecture/deterministic-content-id.md: format spec + rationale for the
  separator choice; this is the design doc the change is derived from.

Client-side: the web frontend treats content_id as an opaque string (no
splitting/regex), so no client changes are required; existing
encodeURIComponent call sites simply stop emitting %3A.

* docs(contentid): show why hash/bigint rejected in probe-cost table

Add Cross-server deterministic / Zero-join show transform / Human-readable
columns to the index-probe-cost comparison so the trade-off is legible at a
glance: the 128-bit hash and bigint surrogate are faster but each give up a
load-bearing property, and the structured key is the only all-checkmark row.

* docs(contentid): order probe-cost table to end on the structured key

* docs(contentid): label fenced blocks and drop stray EOF tags

Per CodeRabbit review: add 'text' language to three fenced code blocks
(MD040) and remove accidental </content></invoke> artifacts at EOF.

* fix(catalog): remap array-valued content_id soft references in deterministic id migration

The value-remap migration (20260612130000) enumerates the reference graph by FK
plus a scalar name+type sweep (text/varchar/bpchar). That misses
trending_discover_snapshots.content_ids: it is text[] (excluded by the type
filter), named content_ids not content_id (excluded by the name list), and
cannot carry an FK — so the bulk remap left those arrays holding stale Sonyflake
ids that resolve to nothing until the snapshot regenerates. A counterexample to
the migration's "self-protecting, cannot orphan" invariant.

Remap the array element-wise in both directions (Up old->new, Down new->old),
preserving order and leaving collision/unmatched elements untouched; a WHERE
EXISTS guard skips empty/unaffected arrays so array_agg never collapses the NOT
NULL column to NULL. Mirror the gap in silo_rename_content_id (20260614120000)
with array_replace for the single-value runtime rename so the two stay in
lockstep.

Verified on PG18: mixed/collision/empty arrays remap correctly and round-trip
clean; runtime array_replace preserves order.

Surfaced reviewing #155. The jellycompat restart-decode regression and the
atomicity-wording nit are posted as review comments, not addressed here.

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

* fix(jellycompat): pack content_id into compat UUID reversibly so item ids survive restarts

Addresses the restart-decode regression raised in review of #155. With
content_id now a structured string instead of a numeric Sonyflake,
EncodeStringID sent every item/season id down the one-way SHA1 path, making
decode depend on an in-memory reverse map. That map is cold after a process
restart (the codec is a process-lifetime singleton), so a client presenting a
previously-issued item UUID — resume-from-home, deep link, detail page, image,
userdata — got "unknown compat id" until the item was re-listed.

Make the encoding reversible instead of stateful:

- internal/contentid: add Pack/Unpack, a bit-packed, fixed-budget (<=15 byte)
  binary form of a structured or local content_id. digitCount preserves
  provider-id leading zeros (e.g. imdb tt0944947); structured forms are
  self-delimiting; the local form fills the budget exactly. Provider ids that
  overflow uint64 return ok=false.
- Shrink ForLocal to a 112-bit (sha256(path)[:14]) hash so a local id packs
  losslessly into the 15-byte UUID payload. 112 bits is far beyond any single
  server's local-item count. No other code assumed the old width.
- internal/jellycompat: EncodeStringID packs item/season content_ids into the
  UUID (byte 0 = kind, bytes 1..15 = packed, non-zero tag distinguishes it from
  the numeric encoding); DecodeStringID unpacks first and re-packs to confirm,
  so an opaque id whose bytes merely parse is rejected and falls through to the
  map. Numeric ids and arbitrary names (genres, studios) are unchanged.

Net: item/season ids decode by pure computation — stable across restarts and
across instances — with no lookup table. Only the rare unpackable content_id and
non-content names still use the in-memory map.

TDD: round-trip property tests in contentid (all kinds, leading zeros, reject
cases) and a cross-instance decode test in jellycompat that fails on the old
hash+map path. Full contentid + jellycompat suites green; production code
golangci-clean.

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

* fix(migrate): make the migration run timeout configurable (SILO_MIGRATE_TIMEOUT)

The boot-path migration runner hardcoded a 5-minute context timeout. The
deterministic-content-id value-remap (20260612130000) does a full-table COLLATE
rewrite + 65-column remap that needs ~20 min on a real dataset (615k items /
2M episodes), so it was cancelled at 5 min. Worse, Postgres keeps the orphaned
backend running (holding AccessExclusive locks) until it notices the dead client
at a statement boundary, while the goose session advisory lock releases on
disconnect — so each 5-min boot retry piled a new attempt behind the previous
one's locks. The migration never applied; the server boot-looped.

Make the timeout configurable via SILO_MIGRATE_TIMEOUT (a Go duration like
"60m"); 0 or negative disables the deadline for a one-off heavy migration. Default
stays 5m. All three entry points (migrate-status, --migrate-only, boot) honor it.

Required for the deterministic-content-id migration to apply on any real-sized
database, not just dev — the 5m cap made the PR undeployable at scale.

Follow-up (not here): on cancellation the runner should actively terminate its
backend so a future timeout cannot orphan a lock-holding statement.

TDD: MigrationTimeout parsing (default/override/zero/invalid) + MigrationContext
deadline behavior.

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

* fix(contentid): require exact length for local ids in Unpack

Tighten the tagLocal branch of Unpack from `len(body) < localHashLen` to an
equality check. The local form fills the compat-UUID payload exactly (no
padding), so a body of any other length is non-canonical; matching it exactly
keeps Unpack a strict fail-closed inverse of Pack for the fixed-length branch,
which decodes client-supplied UUIDs.

Not applied to the structured branch (a review suggestion proposed the same
change there): structured ids are self-delimiting and the compat layer pads them
with trailing zeros to fill the 15-byte UUID payload, so ignoring trailing bytes
is intentional and documented. Rejecting them would make every structured id
fail to decode — the jellycompat cross-instance test guards against that.

Not a live bug today (the only caller passes u[1:] from a 16-byte UUID, so body
is always exactly localHashLen, and idcodec re-packs to verify), but it is the
correct contract and zero-risk. Adds a regression test.

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

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:34:13 -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
QuickandClaude Fable 5 799754dde7 feat(notifications): rich Discord embeds with posters, provider links, and ratings
Upgrade all outbound Discord surfaces (personal webhooks, bot DMs, server
channels, request events) from bare title/description embeds to rich ones:
poster thumbnail, overview teaser, TMDB/IMDb/TVDB links, rating and genre
fields, content-rating footer, and a clickable title URL.

Artwork respects the v1 privacy contract via a new admin poster mode
(notifications.discord.poster_mode): "provider" (default) only emits public
provider-CDN URLs, "server" additionally presigns locally cached posters
from this server's image storage, "off" drops images entirely. Builders
never derive artwork URLs themselves; the sender layer resolves PosterURL
through System.discordPosterURL.

To keep provider-CDN URLs derivable after image caching rewrites
poster_path to a local storage key, media_items gains poster_source_path,
captured during cacheItemImages, preserved across refreshes that keep the
cached poster, and cleared on explicit poster overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:32:53 -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 ebf3352bda feat(notifications): per-profile email channel with verified addresses
Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:55:30 -04:00
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00
QuickandClaude Fable 5 df95e3cb95 feat(notifications): email notification channel
Adds email as a notification channel built on the shared SMTP core
(mail.Sender). Email mode is a per-account preference (off, daily
digest, or per-episode) stored in notification_email_prefs; delivery is
an account-watermark sweep over notification_deliveries that dedupes
cross-profile duplicates, advancing the watermark only after a
successful send. Admin controls cover the channel kill switch, the
per-episode allowance (off coerces those accounts to the digest),
digest hour, and an external URL for deep links inside emails.
Availability is advertised through /notifications/capability and the
user settings page gains an Email section for opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:09:37 -04:00
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00
QuickandClaude Fable 5 d9e27da59e feat(notifications): request-fulfilled notifications across all channels
Notify the requesting profile once its media request is actually present
in the catalog (roadmap 06, item 2). Completion transitions stay
notification-agnostic; a presence-gated pass at the end of each
reconcile run fires the notice, so it means "watchable in Silo", not
"download finished".

- New System.DispatchOperational: delivery insert + webhook/web-push
  outbox enqueue in one transaction, post-commit multi-dispatch. The
  webhook auto-disable notice now rides the same path (replacing its
  hand-rolled hub publish and the now-removed InsertOperational), which
  also delivers auto-disable notices over web push.
- At-most-once delivery: partial unique index on
  (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at
  marker on media_requests, backfilled for pre-existing completed
  requests so deploys never flood.
- Per-webhook notify_requests toggle (default on) through repo, service,
  API, and settings UI; gated independently of the episode reason flags.
- request.fulfilled rendering in web inbox, realtime toast, web push
  payload, and Discord/generic webhook payloads, deep-linking to the
  matched catalog item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:04:22 -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
7958f0bbf0 feat(nodepool): node groups, per-node caps, and local transcode fallback control (#126)
* feat(nodepool): node groups, per-node caps, and local transcode fallback control

Group co-located transcode and proxy nodes so transcoded streams are
served by a proxy on the same host/LAN instead of bouncing across the
internal network (fixes #93):

- New nodepool.Planner is the single selection entry point: it picks the
  transcode node and its group's proxy together (round-robin within the
  group), replacing the independent ProxyPool.Pick/TranscodePool.Acquire
  calls scattered across the native and jellycompat handlers, and absorbs
  the duplicated soft-affinity pick logic.
- A group is only eligible while all of its enabled members are healthy;
  ungrouped nodes keep the historical behavior.
- New per-node max_jobs cap (transcodes for transcode nodes, streams for
  proxies; NULL = unlimited), enforced via health-reported job counts
  plus short-lived reservations that expire once fresher health data
  arrives. Proxy health now reports real stream counts, including HLS
  sessions via idle-expiry tracking.
- New playback.local_transcode_fallback setting (default on) lets admins
  refuse API-server transcoding when no eligible node exists.
- Health checks now publish updated node copies under the pool lock
  instead of mutating shared structs in place, fixing a data race.
- Admin UI: group + cap fields on the node form, group/cap columns, and
  the new fallback toggle in playback settings.

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

* feat(nodepool): proxy bandwidth measurement and egress caps

Proxy nodes now measure their stream egress (rolling 60s average over
everything under /stream) and report it via the health endpoint. A new
per-proxy max_bandwidth_kbps cap lets the planner route new streams away
from saturated proxies:

- Admission combines the measured egress with the estimated bitrate of
  the new stream (transcode target bitrate, or source bitrate for direct
  play/remux) so a stream is only admitted where it fits.
- Recently admitted streams are bridged as bandwidth reservations for the
  meter window, since the rolling average only converges on a new
  stream's rate gradually.
- A group whose proxies lack bandwidth headroom is treated as full: its
  transcode nodes are skipped, same as the job cap.
- Admin UI: per-proxy "Max Egress Bandwidth (Mbps)" field and a live
  egress column; manual health checks return the measured rate.

Active streams are never interrupted - the cap only gates new admissions.

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

* perf(playback): trim node-mode time-to-stream-start

Distributed playback paid several avoidable costs before the first frame
that integrated mode doesn't have. This trims the safe ones:

- Web player preconnects to the stream origin (the proxy node) as soon as
  /playback/start returns, overlapping DNS/TCP/TLS handshakes with the
  transcode dispatch instead of paying them at the first manifest fetch.
- The transcode node no longer blocks its 202 on monitoring work: the
  Redis session-track write moves off the request path, and a replaced
  session's segment directory is renamed aside and deleted in the
  background instead of synchronously (RemoveAll of a long session can
  take seconds on slow disks during quality switches).
- The proxy's node-facing HTTP client gets a tuned transport: a larger
  idle-connection pool (Go's default of 2 per host causes connection
  churn and TLS re-handshakes when many viewers stream through one
  proxy->node pair) and a response-header timeout so a hung transcode
  node can no longer hang client requests indefinitely.
- jellycompat's remote transcode dispatch gains the same 10s timeout the
  native path has had; an unreachable node previously hung the compat
  manifest request until the OS gave up.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:18 -04:00
QuickandClaude Fable 5 ed4cebf3ba feat(ai): per-user transcription quota for subtitle ASR jobs
Cap how many Whisper transcription jobs each user account can start per
rolling window (day/week/month), configurable from admin settings. The
player modal shows remaining usage and the server returns 429 with
details when the limit is hit.

Enforcement is atomic with the job insert (per-user advisory lock, same
pattern as media-request quotas), so concurrent requests cannot race
past the limit. Failed/cancelled jobs that never produced transcription
work are refunded. Exemption applies to the admin account's primary
profile only; other profiles on an admin account stay subject to the
quota. A partial index covers the quota count, a malformed quota
setting row degrades to "no quota" instead of blocking startup, and the
period vocabulary and admin-role predicate are each defined once
(ai.ValidQuotaPeriod, apimw.IsAdmin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:02:42 -04:00
39ba284c9d feat(ai): shared AI core — metadata translation, Whisper ASR, per-profile language, on-view translation (#127)
* docs: design + plan for shared AI core, metadata translation, Whisper ASR

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

* refactor(ai): shared LLM client, segment translator, and job runner packages

internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai,
plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared
retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON
translation protocol generalized to text segments. internal/ai/jobrunner:
dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store
interface, with a semaphore shareable across job services.

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

* refactor(subtitles): consume shared AI core

LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the
service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local
OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire
protocol, job rows, recovery semantics) is unchanged. NewService now takes
the dispatch semaphore so all AI job services can share one bound.

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

* feat(config): shared ai.* settings, metadata translation job table, localization provenance columns

ai.* connection keys (chat + optional separate ASR endpoint) load with a
fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL
because encrypted values are GCM-bound to their setting key. New toggles:
subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds
metadata_translation_jobs, per-field provenance (provider|ai|manual) on the
localization tables, and media_folders.auto_translate_metadata.

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

* feat(catalog): localization field provenance with provider/ai/manual precedence

Provider upserts keep manual values and never blank a field with an empty
incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI
fields only over empty or ai-sourced values (force adds provider, never
manual) — all enforced in single-statement SQL. Serving now merges only
non-empty localized fields onto the base item, since localization rows are
legitimately partial (AI rows carry no titles/artwork).

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

* feat(metadata): AI translation service, refresh auto-fallback, and admin API

internal/metadata/translation: job service over the shared AI core that
expands an item to its season/episode overviews, skips already-localized
fields (zero model calls on repeat runs), batches paragraphs through the
generic translator, and persists per batch with provenance-aware upserts.
MetadataService gains an AutoTranslator seam invoked after each refresh for
libraries with auto_translate_metadata. Admin endpoints under the metadata
curation guard: enqueue, list (poll), cancel; plus a status probe.

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

* feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs

New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min
16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes
to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json,
per-request timeout sized to 3x chunk duration), segment timestamps are
offset and built into wrapped cues. Chunks process playhead-first and stream
live to the requesting session. The transcript is stored as an ordinary
downloaded subtitle (provider 'transcribed'); transcribe_translate chains
the existing translator and stores the translated track as the job result.
Enqueue accepts an optional kind; status reports transcribe_enabled.

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

* feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio

New AI Services admin page hosts the shared endpoint config (reads fall back
to legacy subtitle_ai.* values, writes target ai.*) and the three feature
toggles; the AI card moves out of Subtitles settings. The metadata editor
gains a Translate-with-AI panel with job polling and force/re-translate. The
library form gains the auto-translate toggle (threaded through the libraries
API). The player translate modal gains a From-audio mode that lists audio
tracks and submits transcribe / transcribe_translate jobs.

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

* style: gofmt import grouping in router and translation tests

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

* feat(catalog): per-profile metadata language and viewer-triggered description translation

user_profiles.preferred_metadata_language threads through the access scope
into catalog serving: presentation language now resolves explicit param ->
profile preference -> library metadata language (native API and jellycompat).
ItemDetail gains pending_translation_language when the viewer's language is
missing a localized overview. New metadata_ai.on_view setting (off|button|
auto) gates POST /items/{id}/translate-description: any profile with item
access may request its language, with in-flight dedup and a 15-minute
failure cooldown so page views never hammer a broken endpoint.

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

* feat(web): on-view description translation with per-profile metadata language

Profile playback settings gain a Metadata language picker (library default
inherit). Detail pages: when the server reports pending_translation_language
and metadata_ai.on_view is 'auto', the description translates on view with a
pulse animation until the refetched detail comes back localized (45s
timeout); in 'button' mode a small Translate chip triggers the same flow.

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

* fix(web): expose metadata_ai.on_view in AI Services settings

The on-view translation mode had no UI control, so it could only ever be
'off' — viewers got neither the auto translation nor the fallback button.
Adds the off/button/auto selector to the Features card, and the config
loader now warns and falls back to 'off' on a bad row instead of refusing
to start.

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

* fix(ai): clear configuration hint when the transcription endpoint is chat-only

A blank Transcription base URL falls back to the chat endpoint; chat-only
gateways reject the multipart upload with an opaque 400 that reads like a
pipeline bug. 400/404/405 transcription failures now carry a hint to set a
Whisper-compatible endpoint in AI Services.

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

* fix(subtitles): wrap ASR cue text by rune count, not bytes

Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based
wrapping broke lines at roughly half the intended visual width.

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

* docs(web): steer transcription base URL hint away from chat-only gateways

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

* feat(ai): block chat-only gateways for transcription, add endpoint presets

llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API)
is enforced in three layers: the settings API rejects ai.asr_base_url values
pointing at one, the router disables ASR with a warning when the blank-URL
fallback would land on one, and llm.Transcribe refuses outright. The AI
Services page gains one-click transcription presets (Groq turbo/accurate,
OpenAI, self-hosted speaches) plus the mirrored client-side check, and the
settings API now also validates metadata_ai.on_view.

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

* fix(subtitles): tighten ASR subtitle sync

Three systematic timing-error sources addressed: cue offsets now use the
segment muxer's exact per-chunk start times (segment_list CSV) instead of
assuming index*chunk_seconds; the audio stream's start delay relative to the
container timeline (common in TS remuxes) is probed via ffprobe and added to
every cue; and the chunk length is now operator-tunable via
subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks
bound Whisper's within-chunk timestamp drift. Playhead-first ordering now
pivots on real chunk starts, and a beyond-end playhead starts at the final
chunk instead of restarting from zero.

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

* fix(ai): tolerate base URLs that already include the /v1 segment

Providers like DeepInfra expose their OpenAI-compatible API under a base
that contains the version segment (api.deepinfra.com/v1/openai); always
appending /v1/... mangled those. endpointURL now appends bare paths when
the base already carries /v1.

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

* feat(web): prefer self-hosted transcription in presets and hints

Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq
large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with
the self-hosted option. The self-hosted preset now fills the turbo CT2 model
to match the recommended speaches setup.

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

* fix(subtitles): request VAD and word timestamps for ASR cue accuracy

Without vad_filter, faster-whisper servers report wall-to-wall segment
times: cues linger on screen through silence (verified up to 91s) and
paragraph-length segments become single 400+ char cues. Request
vad_filter=true (skipped for hosted providers that reject non-OpenAI
fields and run VAD server-side) plus timestamp_granularities word+segment,
and rebuild cues from word timings: split at speech pauses, sentence ends,
text capacity, and a 7s max duration; cap word-less segments instead of
trusting their reported end; stretch sub-second cues to a readable minimum.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:58:54 -04:00
Quick c7f65bf351 fix(audiobooks): normalize ABS session token hash 2026-06-10 11:56:59 -04:00
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

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

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

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

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

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

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

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

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

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

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

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

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

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

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

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

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

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

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

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

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

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

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

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

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

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

* docs: record the PR #124 review and hardening pass

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00
46540dfec3 fix(progress): track resume points independently of watched state (#117)
* fix(progress): track resume points independently of watched state

Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).

Adopt the Jellyfin invariant instead of guard heuristics:

- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
  SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
  position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
  Continue Watching through plain GREATEST/MAX while the watched flag and
  PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
  the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
  (resumePositionTicks no longer zeroes played items) — the DTO shape real
  Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
  stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
  via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
  0: a Goose migration for Postgres and a user_version-gated one-time fix
  for the per-user SQLite DBs.

Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.

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

* fix(progress): address review — migration gate, one-way latch, missed writers/readers

Review fixes for the position-based watch-progress model:

- The per-user SQLite data fix is now migrateToV11 in the existing
  versioned runMigrations chain (schemaVersion 11). The previous
  standalone PRAGMA gate compared against 1, but existing DBs already
  sit at user_version 10, so the reset never ran for them — and the
  gate would have rewound the version. Fresh DBs short-circuit to the
  current version as before.
- `completed` is now one-way across every playback/sync writer:
  SetProgress (the RecordPlaybackStop path — stopping a rewatch below
  the watched threshold no longer clears the watched state),
  SetProgressAt, SetProgressIfNewer (both stores), and the history
  import upsert, which also stops pinning completed imports to
  position = duration. Mark-unwatched still releases the latch via
  ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
  carrying an old timestamp can no longer zero a newer rewatch resume
  point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
  (position_seconds > 0, completed-agnostic): smart-collection
  in_progress filter, progress sort ratio, episode progress CTE, and
  both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
  the same clamped position; a played item at rest reports 100 (as the
  old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
  books can't surface as phantom resume entries; re-listens still move
  position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
  mirroring the server invariant until the refetch lands.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:44:40 -04:00
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* feat(audiobooks): redesign audiobook library around resume and series progression

Audiobook libraries previously reused the video-shaped library page: a
backdrop carousel hero (audiobooks have square covers and no backdrops),
movie-style default sections, and a browse grid whose primary audiobook
axes (author, narrator, series) were buried as filters.

Backend:
- New next_in_series section type: surfaces the next unstarted book, by
  series_index, in series the profile has finished a book of, ordered by
  most recent finish. Registered as a library-staple recipe.
- New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by
  author/narrator/series with book count, total duration, per-profile
  progress counts, and poster URLs for cover stacks.
- Audiobook library defaults: continue-listening is featured (renders as
  the Now Listening hero) with next-in-series directly after it. A data
  migration upgrades existing audiobook libraries, skipping layouts where
  an admin already featured a section.

Frontend:
- NowListeningHero replaces HeroBanner for audiobook libraries: resume
  deck with chapter position, hours left, ambient color from the cover,
  and one-click resume; remaining in-progress books render as the
  Continue Listening row.
- Library tab gains Books/Series/Authors/Narrators browse axes persisted
  via the type param; selecting a group drops into the Books grid with
  the matching filter applied.
- "Recommended" tab is labeled "Home" for audiobook libraries; audiobook
  continue cards use square covers and hr/min time-left formatting.
- Shared audiobook chapter/file/duration helpers extracted to
  web/src/lib/audiobooks (deduplicated from AudiobookContent).

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

* fix(audiobooks): address review feedback on library redesign

- Push library scoping into the next-in-series candidate SQL so finished
  series whose next book lives in another library can't consume the
  candidate limit and starve a library-scoped section (Codex P2).
- Paginate the audiobook groups fetch until the server-reported total is
  reached (500/page, 20-page bound) so client-side filtering sees the
  complete author/narrator/series list (Codex P2, CodeRabbit).
- Make the redesign migration rollback-safe: rows the Up touches carry
  config markers (featured_by_migration / seeded_by_migration) and the
  Down reverts only marked rows, leaving admin-set featured state and
  hand-created next_in_series sections alone (Codex P2, CodeRabbit).
- Gate NowListeningHero's detail-derived files/credits on the detail
  matching the deck item, so Resume can't start the new book with the
  previous book's files while keepPreviousData shows stale detail
  (Codex P2).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:28:53 -04:00
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

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

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

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

* feat(db): generalize request_integrations into a two-tier connection registry

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

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

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

* feat(requests): plugin-backed RequestRouterProvider seam

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

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

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

* fix(requests): base auto-approve gate on router connection model

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

* feat(api): wire plugin-backed request router at both service sites

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

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

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

* feat(web): plugin-driven request integration config form

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

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

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

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

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

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

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

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

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

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

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

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

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

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

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

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

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

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

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

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

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

* feat(requests): RequestRouter Validate client + provider seam

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

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

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

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

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

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

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

* fix(web): silent connection-options probe with inline failure status (no toast spam)

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

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

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

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

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

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

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

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

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

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

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

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

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

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

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

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

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

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

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

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

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

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

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

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

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

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

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

* feat(web): indent show_when-revealed schema fields to read as nested

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

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

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

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

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

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

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

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

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

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

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

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

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

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

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

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

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

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

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

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

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

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

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

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

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

* style(web): prettier-format schema-form utils to 100-col width

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

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

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

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

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

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

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

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

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

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

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

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00
5855cebe75 fix(audiobooks): complete playback and catalog parity (#96)
* fix(audiobooks): complete playback and catalog parity

* fix(web): allow podcast continue targets

* fix(audiobooks): use folder sidecar covers during scan

* fix(audiobooks): read nullable poster paths during cover scan

* feat(home): split continue listening sections

* fix(catalog): coalesce nullable media artwork fields

* feat(playback): surface audiobook sessions in admin activity

* fix(audiobooks): address playback review feedback

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:55:35 -04:00
339ed20074 fix(audiobooks): improve poster enrichment throughput (#94)
* fix(audiobooks): improve poster enrichment throughput

* fix(audiobooks): enable ABS compat by default

* chore(audiobooks): remove stale feature flag setting

* fix(audiobooks): validate scoped scan roots

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
2026-06-08 18:17:08 -04:00
zZebrahzandGitHub 96bae2f346 fix(library): clean orphaned provisional items (#76)
* fix(library): clean orphaned provisional items

* test(metadata): use item not found sentinel in fake repo

* fix(library): preserve abs references during orphan cleanup

* fix(library): avoid dropped abs cleanup tables

* fix(library): harden provisional orphan cleanup

* fix(library): preserve home dismissal series orphans

* fix(library): Delete matched items created after the first orphan sweep
2026-06-07 21:32:40 -04:00