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>
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>
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>
* 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>
* feat(security): encrypt server-owned credentials at rest
Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required
SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned
enc:v1: envelope, and an idempotent startup backfill.
- internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine.
- SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency.
- server_settings: EncryptedSettingsRepo decorator over the audited
SensitiveSettingKeys (also drives admin redaction); the config watcher and
watch-sync settings reads decrypt too.
- Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed
from requests/autoscan.
- Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not
webhook_secret), history-import, and the jellycompat session's bridged Silo
access/refresh tokens.
- Startup backfill (resolve-then-encrypt for arr refs) is best-effort and
primary-node gated.
Equality-looked-up secrets and plugin_runtime_configs.config_value are out of
scope (need hashing / cross-repo design) — see
docs/architecture/secret-encryption.md.
Refs #45
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(compose): require SECRET_KEY in docker-compose
The server now fatals without SECRET_KEY, so the integrated service (and the
commented distributed proxy/transcode examples) pass it through with a
fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker
nodes must use the SAME key as the primary to decrypt shared data.
Generate with: openssl rand -base64 48.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(security): encrypt history import session credentials
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design spec for multi-instance Sonarr/Radarr request routing
Seerr-style multi-instance arr management inside Silo's request system:
many instances per kind, HD/4K default routing, entitlement-driven
dual-quality fan-out, per-instance anime overrides (keyword 210024),
and a one-to-many media_request_targets model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for multi-instance arr request routing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(requests): migration for multi-instance arr routing
Adds migration 169 to convert request_integrations from a one-row-per-kind
table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K
defaults, anime overrides, and a new one-to-many media_request_targets table
for per-quality fulfillment tracking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): instance, target, and dual-quality types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): id-based integration CRUD
Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with
GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration,
and ClearDefault. Rewrites scanIntegration and integrationColumns to cover
all new multi-instance columns (id, name, is_4k, is_default, is_default_4k,
anime_* fields). Updates the Store interface accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): target persistence and aggregate status
* feat(tmdb): expose keyword ids and original language on detail
* feat(requests): Seerr-exact anime detection (keyword 210024)
* feat(requests): quality/anime routing engine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(requests): force_dual_quality setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): multi-instance request integration types and CRUD hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): multi-instance arr manager, dual-quality toggle, per-target queue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status)
* fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found)
- routing: anime fields only override standard root/profile/tags when set,
so enabling anime with blank fields reuses standard values instead of
clearing them into an invalid submission
- api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null
- web: require an API key before saving a NEW instance; add aria-expanded/
aria-controls to the anime-overrides disclosure toggle
- repo: DeleteTarget returns ErrNotFound when no row was deleted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(requests): address PR review findings
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
- GetExternalIDs now uses the dedicated /movie/{id}/external_ids and
/tv/{id}/external_ids endpoints instead of fetching the full detail
with append_to_response=external_ids. The dedicated payload is
one or two orders of magnitude smaller for the same fields.
- Document PosterPath/BackdropPath on MediaResult as raw TMDB path
fragments that callers must prefix with the image base URL.
- normalizeCast switches from inline insertion sort to sort.SliceStable.
The output is identical; the new form is one line and O(n log n).
- normalizeIntegration no longer reuses integration.Tags' backing
array via Tags[:0]; the slice is callable code, so reusing the
array would silently corrupt the caller's slice if it kept a
reference. Allocate a fresh slice instead.
- HandleGet now requires a profile, matching the rest of the
/requests user-group handlers. Router middleware enforces this
already, but the inline check is defense-in-depth for any future
remount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The radarr and sonarr clients carried byte-identical copies of
rootFolderResource, qualityProfileResource, tagResource (and the
corresponding list helpers) plus acceptedWithoutResponse and
statusFromQueueEvaluation. Move the shared wire types and helpers
into the arrclient package and update the callers to use the
exported helpers. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each homepage discovery section fired a serial TMDB round trip with
its own presence lookup, so the response time grew linearly with the
number of sections (~1.2 s at 6 sections * 200 ms). Fan the calls out
across a bounded errgroup using the same concurrency cap as
external-id hydration. The first section to error cancels the rest
via the group context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owners can now POST /requests/{id}/cancel to withdraw a pending
request; admins can cancel any active request that has not entered
the fulfillment pipeline. The route is mounted on both the user
group (with profile required) and the admin group. The cancelled
outcome was already reserved in the migration's CHECK constraint
but was unreachable from any handler.
Decline now also rejects approved requests — between Approve setting
StatusApproved and the reconciler picking the request up, an admin
could declare the request declined while submission was about to
fire. The reconciler's outcome filter would skip the request, but
the narrow window meant external state could diverge from Silo's
view. Refuse decline once a request is approved; callers should
wait for completion or use the failed/retry path.
Reconcile now emits a slog.WarnContext at the per-request failure
site with request id, media type, tmdb id, status, and integration
kind. Aggregated counters in ReconcileResult are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radarr and Sonarr can return HTTP 201 with no body when a movie or
series is added. The previous code returned an "accepted_without_response"
result with an empty ExternalID, which trapped the reconciler: every
subsequent CheckStatus call short-circuited on the empty ID and the
request never advanced past queued.
When the add POST decodes empty, look the freshly-added record up by
TMDB or TVDB ID via the standard list endpoints and use the resulting
Arr ID. Fall back to the previous accepted-without-response result
only when the lookup also returns no match, preserving the original
behavior as a safety net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CreateRequest previously read the user's request count outside the
insert transaction, so two concurrent submissions at MaxRequests-1
could both pass the quota gate and end up at MaxRequests+1. Move the
count inside the same transaction as the insert and acquire a per-user
advisory lock so concurrent inserts serialize. The store reports
ErrQuotaExceeded when the racing path catches the user at the limit
and the service maps it back to QuotaError.
normalizeListFilter previously reset limit to 50 when callers asked
for more than 100, which is surprising. Clamp to the cap instead so a
request for 150 returns 100 and a request for 1_000_000 still cannot
hit the database with an unbounded scan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Batch integration upserts in a single transaction
- Treat radarr/sonarr lookup results as arrays and require exact matches
- Prefer queue failures over downloading state when evaluating arr queues
- Allow retrying queued/downloading requests and block declines once fulfillment started
- Fall back to pending when auto-approval integration check fails
- Rename requests query hooks file and fix discover card request affordance
- Add `media_type=all` to request search, backed by TMDB `/search/multi` filtered to movies and series
- Default the Requests page filter to All and refresh search results grid styling
- Refine RequestPosterCard with status accent bar, richer fallback poster, and fluid grid layout
Wire curated TMDB-backed studios/networks/genres discovery into the requests service and UI, replacing on-demand logo fetches with fixed duotone logos and adding browse routes plus tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add GetMediaDetail TMDB client returning normalized detail with cast, crew, recommendations, and certifications
- Add /api/requests/detail/{media_type}/{tmdb_id} endpoint overlaying availability and request state
- Add RequestDetail page and link poster cards to it
- Treat empty/truncated Radarr/Sonarr POST responses as accepted; drop pre-submit existence lookups
- Add request domain, repository, service, and reconcile task
- Add Radarr/Sonarr fulfillment adapters and TMDB discovery
- Expose user and admin request APIs with quota and approval rules
- Add web UI for browsing, requesting, and admin queue management
- Migration 139 introduces media_requests and related tables