Files
silo-server/internal/requests/service.go
T
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

1854 lines
57 KiB
Go

package requests
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
"golang.org/x/sync/errgroup"
)
type TMDBClient interface {
SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error)
DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error)
GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error)
DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error)
}
type TMDBExternalIDClient interface {
GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error)
}
const externalIDHydrationConcurrency = 4
type EntitlementResolver interface {
// MaxPlaybackQuality returns the requester's effective playback-quality
// ceiling (already combining account- and profile-level caps). Empty string
// means "no cap".
MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error)
}
// RequesterIdentityResolver resolves a requesting user id into the identity a
// per-user request_router plugin needs (e.g. Seerr attribution by email).
type RequesterIdentityResolver interface {
ResolveRequester(ctx context.Context, userID int) (email, username string, err error)
}
type Service struct {
store Store
tmdb TMDBClient
presence PresenceResolver
router RequestRouterProvider
entitlements EntitlementResolver
groupProvider access.GroupPolicyProvider
requesterIdentity RequesterIdentityResolver
notifier FulfillmentNotifier
lifecycle LifecycleNotifier
Now func() time.Time
}
type DiscoverySection struct {
Key string `json:"key"`
Title string `json:"title"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
Results []MediaResult `json:"results"`
}
func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service {
return &Service{
store: store,
tmdb: tmdbClient,
presence: presence,
Now: func() time.Time { return time.Now().UTC() },
}
}
func (s *Service) SetRouterProvider(p RequestRouterProvider) { s.router = p }
func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }
func (s *Service) SetGroupPolicyProvider(p access.GroupPolicyProvider) { s.groupProvider = p }
func (s *Service) SetRequesterIdentityResolver(r RequesterIdentityResolver) {
s.requesterIdentity = r
}
// populateRequesterIdentity fills req.RequesterEmail/Username from the resolver.
// Nil resolver or any error leaves them empty (the plugin then behaves as admin).
func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) {
if s.requesterIdentity == nil || req.RequestedByUserID <= 0 {
return
}
email, username, err := s.requesterIdentity.ResolveRequester(ctx, req.RequestedByUserID)
if err != nil {
slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "user_id", req.RequestedByUserID, "error", err)
return
}
req.RequesterEmail, req.RequesterUsername = email, username
}
func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string {
if s.entitlements == nil {
return "" // no resolver -> unlimited (1080p baseline still applies)
}
q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID)
if err != nil {
return access.PlaybackQualityStandard // fail safe: HD only
}
return q
}
// allowedQualities returns the qualities a request may receive: 1080p always,
// plus 2160p when force-dual is on or the requester's entitlement ceiling allows 4K.
func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) []Quality {
out := []Quality{Quality1080p}
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID)
// QualityAllowed treats an empty ceiling as "no cap" (the "Any" preset), so a
// requester with unlimited playback quality correctly gets 4K. A raw
// CompareQuality would rank "" as the LOWEST quality and wrongly drop 4K.
if settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling) {
out = append(out, Quality2160p)
}
return out
}
// fulfillContext caches the global fulfillment inputs for one reconcile cycle
// (or a single Approve/Retry) so integrations and settings are fetched once
// instead of per request. API keys need no cache here: the repository decrypts
// api_key_ref on read, so Integration.APIKeyRef already holds the literal key.
type fulfillContext struct {
integrations []Integration
settings Settings
}
func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error) {
integrations, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
return &fulfillContext{integrations: integrations, settings: settings}, nil
}
// resolveRouterConnections turns enabled request_router integrations that serve
// the given media type into ResolvedRouterConnections (api key resolved to
// plaintext, plugin_config attached), and returns the installation+capability to
// dispatch to.
//
// It filters by media type to match the integrationConfigured auto-approve gate
// (so a series-only connection is never used for a movie request). Multi-
// installation routing isn't supported yet: it picks the first eligible
// connection's installation and includes ONLY connections belonging to it, so a
// second installation's resolved plaintext credentials are never handed to the
// first plugin. A connection whose api key cannot be resolved (or resolves empty)
// is skipped rather than aborting the whole request — a sibling healthy
// connection can still fulfill it, and an unauthenticated request is never sent.
func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillContext, mediaType MediaType) ([]ResolvedRouterConnection, int, string, error) {
var conns []ResolvedRouterConnection
installationID, capabilityID := 0, ""
chosen := false
for _, in := range fc.integrations {
if !eligibleRouterConnection(in, mediaType) {
continue
}
// Contain to the first chosen (installation, capability): a plugin may
// expose more than one request_router capability, and a connection of a
// different capability must never be handed to the chosen one.
if chosen && (*in.InstallationID != installationID || in.CapabilityID != capabilityID) {
continue
}
// in.APIKeyRef was decrypted by the repo on read; empty means unconfigured.
apiKey := strings.TrimSpace(in.APIKeyRef)
if apiKey == "" {
slog.WarnContext(ctx, "requests: skipping router connection with no api key", "connection_id", in.ID)
continue
}
// Lock on the first SUCCESSFULLY resolved connection so a skipped
// bad-key connection never pins the installation/capability.
if !chosen {
installationID, capabilityID, chosen = *in.InstallationID, in.CapabilityID, true
}
conns = append(conns, ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig})
}
return conns, installationID, capabilityID, nil
}
// eligibleRouterConnection reports whether a connection is a candidate fulfillment
// backend for the media type: enabled, bound to an installation, and naming a
// capability sub-id that serves the media type. resolveRouterConnections (which
// then resolves credentials) and integrationConfigured (the auto-approval gate)
// share this predicate so the two cannot drift.
func eligibleRouterConnection(in Integration, mediaType MediaType) bool {
return in.Enabled && in.CapabilityID != "" && in.InstallationID != nil &&
integrationSupportsMediaType(in, mediaType)
}
func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
mediaType, err := normalizeSearchMediaType(mediaType)
if err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("%w: query is required", ErrInvalidInput)
}
raw, err := s.tmdb.SearchMedia(ctx, string(mediaType), query, page)
if err != nil {
return nil, err
}
return s.enrichPage(ctx, viewer, raw)
}
func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, page int) (*DiscoverySection, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
section = strings.TrimSpace(section)
if _, ok := discoverySectionTitles[section]; !ok {
return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput)
}
raw, err := s.tmdb.DiscoverSection(ctx, section, page)
if err != nil {
return nil, err
}
enriched, err := s.enrichPage(ctx, viewer, raw)
if err != nil {
return nil, err
}
return &DiscoverySection{
Key: section,
Title: discoverySectionTitles[section],
Page: enriched.Page,
TotalPages: enriched.TotalPages,
TotalResults: enriched.TotalResults,
Results: enriched.Results,
}, nil
}
func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
sections := make([]DiscoverySection, len(discoverySectionOrder))
group, gctx := errgroup.WithContext(ctx)
group.SetLimit(externalIDHydrationConcurrency)
for i, key := range discoverySectionOrder {
i, key := i, key
group.Go(func() error {
section, err := s.Discover(gctx, viewer, key, 1)
if err != nil {
return err
}
sections[i] = *section
return nil
})
}
if err := group.Wait(); err != nil {
return nil, err
}
return sections, nil
}
// GetDetail fetches a TMDB detail payload and overlays the same availability /
// request-state signals used by search and discovery. Recommendations carry
// their own per-item state so the detail page can render them as request cards.
func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*MediaDetail, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
mediaType, err := normalizeMediaType(mediaType)
if err != nil {
return nil, err
}
if tmdbID <= 0 {
return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput)
}
raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID)
if err != nil {
return nil, err
}
if raw == nil {
return nil, ErrNotFound
}
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
if err != nil {
return nil, err
}
primaryPresence, err := s.lookupAvailable(ctx, mediaType, []int{raw.ID})
if err != nil {
return nil, err
}
primaryMatch := primaryPresence[raw.ID]
primaryRequests, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{raw.ID})
if err != nil {
return nil, err
}
detail := &MediaDetail{
MediaType: mediaType,
TMDBID: raw.ID,
IMDbID: raw.IMDbID,
Title: raw.Title,
OriginalTitle: raw.OriginalTitle,
Tagline: raw.Tagline,
Overview: raw.Overview,
PosterPath: raw.PosterPath,
BackdropPath: raw.BackdropPath,
ReleaseDate: raw.ReleaseDate,
Year: raw.Year,
Runtime: raw.Runtime,
Genres: raw.Genres,
VoteAverage: raw.VoteAverage,
VoteCount: raw.VoteCount,
Status: raw.Status,
Homepage: raw.Homepage,
ContentRating: raw.ContentRating,
ProductionCompanies: raw.ProductionCompanies,
NumberOfSeasons: raw.NumberOfSeasons,
NumberOfEpisodes: raw.NumberOfEpisodes,
FirstAirDate: raw.FirstAirDate,
LastAirDate: raw.LastAirDate,
Networks: raw.Networks,
Director: raw.Director,
Creators: raw.Creators,
Availability: availabilityValue(primaryMatch.Available),
LibraryContentID: primaryMatch.ContentID,
Request: requestStateFor(viewer, policy, primaryMatch.Available, primaryRequests[raw.ID]),
}
if raw.TVDBID > 0 {
tvdb := raw.TVDBID
detail.TVDBID = &tvdb
}
if len(raw.Cast) > 0 {
detail.Cast = make([]MediaCastMember, 0, len(raw.Cast))
for _, member := range raw.Cast {
detail.Cast = append(detail.Cast, MediaCastMember{
Name: member.Name,
Character: member.Character,
ProfilePath: member.ProfilePath,
Order: member.Order,
})
}
}
if len(raw.Recommendations) > 0 {
recPage := &tmdb.MediaPage{Results: raw.Recommendations}
enriched, err := s.enrichPage(ctx, viewer, recPage)
if err != nil {
return nil, err
}
detail.Recommendations = enriched.Results
}
return detail, nil
}
func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input CreateRequestInput) (*Request, error) {
if err := validateViewer(viewer); err != nil {
return nil, err
}
if s == nil || s.store == nil {
return nil, fmt.Errorf("request service is not configured")
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
if err := s.ensureViewerRequestsAllowed(ctx, viewer.UserID); err != nil {
return nil, err
}
normalized, err := normalizeCreateInput(input)
if err != nil {
return nil, err
}
s.enrichExternalIDs(ctx, &normalized)
isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID)
matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)})
if err != nil {
return nil, err
}
if matches[normalized.TMDBID].Available {
return nil, ErrAlreadyAvailable
}
active, err := s.store.ListActiveByTMDB(ctx, normalized.MediaType, []int{normalized.TMDBID})
if err != nil {
return nil, err
}
if active[normalized.TMDBID] != nil {
return nil, ErrAlreadyRequested
}
// Re-requesting media that previously failed (e.g., transient integration
// error) should not leave stale failed rows behind in user/admin lists.
if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil {
return nil, err
}
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
if err != nil {
return nil, err
}
if err := validateCreatePolicy(policy); err != nil {
return nil, err
}
id, err := idgen.NextID()
if err != nil {
return nil, err
}
status := StatusPending
if policy.AutoApprove {
configured, err := s.integrationConfigured(ctx, normalized.MediaType)
if err == nil && configured {
status = StatusApproved
}
}
record := CreateRequestRecord{
ID: id,
Input: normalized,
Status: status,
Outcome: OutcomeActive,
IsAnime: isAnime,
Requester: viewer,
Now: s.now(),
}
if !policy.Unlimited {
record.Quota = &QuotaCheck{
UserID: viewer.UserID,
WindowStart: policy.WindowStart,
MaxRequests: policy.MaxRequests,
}
}
req, err := s.store.CreateRequest(ctx, record)
if err != nil {
if errors.Is(err, ErrAlreadyRequested) {
return nil, ErrAlreadyRequested
}
if errors.Is(err, ErrQuotaExceeded) {
return nil, QuotaError{
Used: policy.MaxRequests,
Limit: policy.MaxRequests,
WindowDays: policy.WindowDays,
}
}
return nil, err
}
s.notifyLifecycle(ctx, *req, LifecycleNotifier.RequestSubmitted)
if req.Status == StatusApproved {
// Auto-approval is a real approval transition; channels subscribed to
// approvals see it alongside the submission.
s.notifyLifecycle(ctx, *req, LifecycleNotifier.RequestApproved)
return s.submitApprovedRequest(ctx, *req, viewer, nil)
}
return req, nil
}
func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) {
if viewer.UserID == 0 {
return nil, ErrForbidden
}
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
reqs, err := s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter))
if err != nil {
return nil, err
}
if err := s.attachTargets(ctx, reqs...); err != nil {
return nil, err
}
if err := s.attachLibraryContent(ctx, reqs...); err != nil {
return nil, err
}
return reqs, nil
}
func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter))
if err != nil {
return nil, err
}
if err := s.attachTargets(ctx, reqs...); err != nil {
return nil, err
}
if err := s.attachLibraryContent(ctx, reqs...); err != nil {
return nil, err
}
return reqs, nil
}
// attachTargets loads and attaches the per-instance fulfillment targets for each
// request so callers (admin queue, detail view) can surface multi-target status.
func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error {
for _, r := range reqs {
if r == nil {
continue
}
targets, err := s.store.ListTargets(ctx, r.ID)
if err != nil {
return err
}
r.Targets = targets
}
return nil
}
func (s *Service) attachLibraryContent(ctx context.Context, reqs ...*Request) error {
if s == nil || s.presence == nil || len(reqs) == 0 {
return nil
}
type requestKey struct {
mediaType MediaType
tmdbID int
}
candidatesByType := map[MediaType][]PresenceCandidate{}
requestsByKey := map[requestKey][]*Request{}
seen := map[requestKey]bool{}
for _, req := range reqs {
if req == nil || req.TMDBID <= 0 {
continue
}
key := requestKey{mediaType: req.MediaType, tmdbID: req.TMDBID}
requestsByKey[key] = append(requestsByKey[key], req)
if seen[key] {
continue
}
seen[key] = true
candidatesByType[req.MediaType] = append(candidatesByType[req.MediaType], requestPresenceCandidate(*req))
}
for mediaType, candidates := range candidatesByType {
matches, err := s.lookupPresence(ctx, mediaType, candidates)
if err != nil {
return err
}
for tmdbID, match := range matches {
if !match.Available || strings.TrimSpace(match.ContentID) == "" {
continue
}
for _, req := range requestsByKey[requestKey{mediaType: mediaType, tmdbID: tmdbID}] {
req.LibraryContentID = match.ContentID
}
}
}
return nil
}
func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) {
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
if err != nil {
return nil, err
}
if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID {
return nil, ErrForbidden
}
if err := s.attachTargets(ctx, req); err != nil {
return nil, err
}
if err := s.attachLibraryContent(ctx, req); err != nil {
return nil, err
}
return req, nil
}
func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Request, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
if err != nil {
return nil, err
}
if req.Outcome != OutcomeActive || req.Status != StatusPending {
return nil, ErrInvalidState
}
approved, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
if err != nil {
return nil, err
}
s.notifyLifecycle(ctx, *approved, LifecycleNotifier.RequestApproved)
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
}
func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
if err != nil {
return nil, err
}
// Approved requests are pending submission by the reconciler; declining
// while submission may be in flight risks a divergent external state.
if req.Outcome != OutcomeActive ||
req.Status == StatusApproved ||
req.Status == StatusCompleted ||
req.Status == StatusQueued ||
req.Status == StatusDownloading ||
strings.TrimSpace(req.ExternalID) != "" ||
strings.TrimSpace(req.IntegrationKind) != "" {
return nil, ErrInvalidState
}
declined, err := s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
if err != nil {
return nil, err
}
declined.DeclineReason = strings.TrimSpace(reason)
s.notifyLifecycle(ctx, *declined, LifecycleNotifier.RequestDeclined)
return declined, nil
}
// Cancel withdraws a request that has not yet been submitted to a downstream
// integration. Owners can cancel their own pending requests; admins can cancel
// any active request that has not entered the fulfillment pipeline. Requests
// already approved, queued, downloading, or completed cannot be cancelled —
// callers should decline (admin) or wait for completion in those cases.
func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
if viewer.UserID == 0 {
return nil, ErrForbidden
}
if !viewer.IsAdmin {
if err := s.ensureRequestsEnabled(ctx); err != nil {
return nil, err
}
}
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
if err != nil {
return nil, err
}
if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID {
return nil, ErrForbidden
}
if req.Outcome != OutcomeActive ||
req.Status == StatusApproved ||
req.Status == StatusCompleted ||
req.Status == StatusQueued ||
req.Status == StatusDownloading ||
strings.TrimSpace(req.ExternalID) != "" ||
strings.TrimSpace(req.IntegrationKind) != "" {
return nil, ErrInvalidState
}
return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason)
}
func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
if err != nil {
return nil, err
}
if req.Outcome != OutcomeFailed {
return nil, ErrInvalidState
}
if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil {
return nil, err
}
// submitApprovedRequest only re-submits qualities lacking a healthy target, so
// it is idempotent; gate it on the approved status it expects.
active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
if err != nil {
return nil, err
}
return s.submitApprovedRequest(ctx, *active, viewer, nil)
}
func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) {
if s == nil || s.store == nil {
return ReconcileResult{}, fmt.Errorf("request service is not configured")
}
if limit <= 0 || limit > 500 {
limit = 100
}
candidates, err := s.store.ListReconciliationCandidates(ctx, limit)
if err != nil {
return ReconcileResult{}, err
}
fc, err := s.newFulfillContext(ctx)
if err != nil {
return ReconcileResult{}, err
}
result := ReconcileResult{Checked: len(candidates)}
for _, req := range candidates {
if err := ctx.Err(); err != nil {
return result, err
}
change, err := s.reconcileRequest(ctx, *req, fc)
if err != nil {
slog.WarnContext(ctx, "request reconcile failed",
"request_id", req.ID,
"media_type", req.MediaType,
"tmdb_id", req.TMDBID,
"status", req.Status,
"integration_kind", req.IntegrationKind,
"err", err,
)
result.Errors++
continue
}
switch change {
case reconcileSubmitted:
result.Submitted++
case reconcileDownloading:
result.Downloading++
case reconcileCompleted:
result.Completed++
case reconcileFailed:
result.Failed++
case reconcileSkipped:
result.Skipped++
}
}
// Presence-gated fulfillment notifications: completion above (and via the
// per-target aggregate path) only marks status; the notification fires
// once the media is confirmed present in the catalog.
if s.notifier != nil {
s.notifyFulfilledPending(ctx)
}
return result, nil
}
func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, error) {
if !viewer.IsAdmin {
return Settings{}, ErrForbidden
}
return s.store.GetSettings(ctx)
}
func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) {
settings, err := s.store.GetSettings(ctx)
if err != nil {
return FeatureStatus{}, err
}
return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil
}
func (s *Service) ensureRequestsEnabled(ctx context.Context) error {
settings, err := s.store.GetSettings(ctx)
if err != nil {
return err
}
if !settings.RequestsEnabled {
return ErrRequestsDisabled
}
return nil
}
func (s *Service) ensureViewerRequestsAllowed(ctx context.Context, userID int) error {
if s.groupProvider == nil {
return nil
}
group, err := s.groupProvider.GetPolicyForUser(ctx, userID)
if err != nil {
return ErrForbidden
}
if group != nil && !group.RequestsAllowed {
return ErrForbidden
}
return nil
}
func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) {
if !viewer.IsAdmin {
return Settings{}, ErrForbidden
}
if settings.GlobalMaxRequests < 0 || settings.GlobalWindowDays <= 0 {
return Settings{}, fmt.Errorf("%w: invalid request settings", ErrInvalidInput)
}
return s.store.UpdateSettings(ctx, settings)
}
func (s *Service) GetUserLimit(ctx context.Context, viewer Viewer, userID int) (*UserLimit, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
if userID <= 0 {
return nil, fmt.Errorf("%w: invalid user id", ErrInvalidInput)
}
limit, err := s.store.GetUserLimit(ctx, userID)
if err != nil {
return nil, err
}
if limit != nil {
return limit, nil
}
return &UserLimit{
UserID: userID,
LimitMode: LimitModeInherit,
ApprovalMode: ApprovalModeInherit,
}, nil
}
func (s *Service) UpsertUserLimit(ctx context.Context, viewer Viewer, limit UserLimit) (*UserLimit, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
normalized, err := normalizeUserLimit(limit)
if err != nil {
return nil, err
}
return s.store.UpsertUserLimit(ctx, normalized)
}
func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
return s.store.ListIntegrations(ctx)
}
func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
id, err := idgen.NextID()
if err != nil {
return nil, err
}
in.ID = id
if err := validateInstance(&in); err != nil {
return nil, err
}
if err := s.validateViaPlugin(ctx, in); err != nil {
return nil, err
}
return s.store.SaveIntegrationWithDefaults(ctx, in, true)
}
func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
if strings.TrimSpace(in.ID) == "" {
return nil, fmt.Errorf("%w: integration id required", ErrInvalidInput)
}
if err := validateInstance(&in); err != nil {
return nil, err
}
if err := s.validateViaPlugin(ctx, in); err != nil {
return nil, err
}
return s.store.SaveIntegrationWithDefaults(ctx, in, false)
}
// validateViaPlugin asks the bound request_router plugin to validate the
// connection config on save. Field/form errors are surfaced as *ValidationError
// so the API layer can render them inline.
func (s *Service) validateViaPlugin(ctx context.Context, in Integration) error {
if s.router == nil || in.InstallationID == nil {
return nil
}
// On UPDATE the client omits api_key_ref ("leave blank to keep saved key"),
// so we would otherwise validate against an empty credential. Mirror
// LoadIntegrationOptions's backfill: load the stored row by id and reuse the
// saved (already-decrypted) api key (and BaseURL/PluginConfig if also blank).
// Nil-safe — a brand-new id has no stored row, so just proceed with what the
// body carries.
if strings.TrimSpace(in.APIKeyRef) == "" && strings.TrimSpace(in.ID) != "" {
stored, err := s.store.GetIntegration(ctx, in.ID)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if stored != nil {
// Don't pair a stored API key with a caller-changed base URL: require the
// key to be re-entered when the server URL changes (defense against
// exfiltrating a stored, API-unreadable key to an attacker-supplied URL).
if strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.BaseURL) != strings.TrimSpace(stored.BaseURL) {
return &ValidationError{FieldErrors: map[string]string{"api_key_ref": "re-enter the API key when changing the base URL"}}
}
in.APIKeyRef = stored.APIKeyRef
if strings.TrimSpace(in.BaseURL) == "" {
in.BaseURL = stored.BaseURL
}
if in.PluginConfig == nil {
in.PluginConfig = stored.PluginConfig
}
}
}
// in.APIKeyRef is the decrypted literal (from the body, or backfilled from the
// stored row above).
apiKey := strings.TrimSpace(in.APIKeyRef)
conn := ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig}
siblings, err := s.siblingConnections(ctx, in)
if err != nil {
return err
}
fe, form, err := s.router.Validate(ctx, *in.InstallationID, in.CapabilityID, conn, siblings)
if err != nil {
return err
}
if len(fe) > 0 || form != "" {
return &ValidationError{FieldErrors: fe, FormError: form}
}
return nil
}
// siblingConnections returns the other connections bound to the same plugin
// installation as `in` (self excluded), carrying only id + config so a plugin
// can enforce cross-connection rules without the host resolving sibling
// credentials.
func (s *Service) siblingConnections(ctx context.Context, in Integration) ([]ResolvedRouterConnection, error) {
if in.InstallationID == nil {
return nil, nil
}
all, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
var out []ResolvedRouterConnection
for _, other := range all {
if other.ID == in.ID || other.InstallationID == nil || *other.InstallationID != *in.InstallationID {
continue
}
out = append(out, ResolvedRouterConnection{ID: other.ID, Config: other.PluginConfig})
}
return out, nil
}
func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error {
if !viewer.IsAdmin {
return ErrForbidden
}
return s.store.DeleteIntegration(ctx, strings.TrimSpace(id))
}
func validateInstance(in *Integration) error {
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
// capability_id carries the capability SUB-ID ("arr"/"seerr"), not the type:
// the host resolves the plugin via requireCapability("request_router.v1", id),
// which keys on (type, id), so storing the type "request_router.v1" here
// resolves nothing. Matches the scan_source/metadata convention
// (autoscan_sources.capability_id = "arr"). The bound plugin's Validate RPC is
// the authority on whether the sub-id names a real capability.
in.CapabilityID = strings.TrimSpace(in.CapabilityID)
if in.CapabilityID == "" {
return fmt.Errorf("%w: capability_id is required", ErrInvalidInput)
}
if in.InstallationID == nil {
return fmt.Errorf("%w: installation_id is required", ErrInvalidInput)
}
// The is_default/is_4k/is_default_4k cross-field consistency check is owned by
// the request_router plugin's Validate RPC, which surfaces it as an inline
// field error (better UX than a generic host 400). See validateViaPlugin.
return nil
}
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (map[string][]RouterOption, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
// For a saved instance the request body carries only the path id (no creds and
// often no plugin wiring), so resolve the saved row by id and backfill what the
// body omitted. This makes "Test connection" reuse the correct per-instance key
// (each plugin can have multiple connections) instead of borrowing a sibling's.
if id := strings.TrimSpace(integration.ID); id != "" && id != "new" {
stored, err := s.store.GetIntegration(ctx, id)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if stored != nil {
submittedBaseURL := strings.TrimSpace(integration.BaseURL)
storedBaseURL := strings.TrimSpace(stored.BaseURL)
if strings.TrimSpace(integration.BaseURL) == "" {
integration.BaseURL = stored.BaseURL
}
if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || submittedBaseURL == storedBaseURL) {
integration.APIKeyRef = stored.APIKeyRef
}
if strings.TrimSpace(integration.CapabilityID) == "" {
integration.CapabilityID = stored.CapabilityID
}
if integration.InstallationID == nil {
integration.InstallationID = stored.InstallationID
}
if integration.PluginConfig == nil {
integration.PluginConfig = stored.PluginConfig
}
}
}
apiKey := strings.TrimSpace(integration.APIKeyRef)
if s.router == nil || integration.InstallationID == nil {
return nil, fmt.Errorf("no fulfillment backend configured")
}
conn := ResolvedRouterConnection{ID: integration.ID, BaseURL: integration.BaseURL, APIKey: apiKey, Config: integration.PluginConfig}
return s.router.ListConfigOptions(ctx, *integration.InstallationID, integration.CapabilityID, conn)
}
func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) {
settings, err := s.store.GetSettings(ctx)
if err != nil {
return EffectivePolicy{}, err
}
limit, err := s.store.GetUserLimit(ctx, userID)
if err != nil {
return EffectivePolicy{}, err
}
policy := EffectivePolicy{
RequestsEnabled: settings.RequestsEnabled,
MaxRequests: settings.GlobalMaxRequests,
WindowDays: settings.GlobalWindowDays,
AutoApprove: settings.GlobalAutoApprovalEnabled,
}
if policy.WindowDays <= 0 {
policy.WindowDays = 7
}
if limit != nil {
switch limit.LimitMode {
case LimitModeBlocked:
policy.Blocked = true
case LimitModeUnlimited:
policy.Unlimited = true
case LimitModeCustom:
if limit.MaxRequests != nil {
policy.MaxRequests = *limit.MaxRequests
}
if limit.WindowDays != nil && *limit.WindowDays > 0 {
policy.WindowDays = *limit.WindowDays
}
}
switch limit.ApprovalMode {
case ApprovalModeBlocked:
policy.Blocked = true
case ApprovalModeManual:
policy.AutoApprove = false
case ApprovalModeAuto:
policy.AutoApprove = true
}
}
policy.WindowStart = s.now().AddDate(0, 0, -policy.WindowDays)
if !policy.Unlimited {
used, err := s.store.CountUserRequestsSince(ctx, userID, policy.WindowStart)
if err != nil {
return EffectivePolicy{}, err
}
policy.Used = used
policy.Remaining = policy.MaxRequests - used
if policy.Remaining < 0 {
policy.Remaining = 0
}
}
return policy, nil
}
func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage) (*MediaPage, error) {
if raw == nil {
return &MediaPage{Results: []MediaResult{}}, nil
}
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
if err != nil {
return nil, err
}
idsByType := map[MediaType][]int{}
for _, item := range raw.Results {
mediaType, err := normalizeMediaType(MediaType(item.MediaType))
if err != nil || item.ID <= 0 {
continue
}
idsByType[mediaType] = append(idsByType[mediaType], item.ID)
}
available := map[MediaType]map[int]PresenceMatch{}
active := map[MediaType]map[int]*Request{}
for mediaType, ids := range idsByType {
presence, err := s.lookupAvailable(ctx, mediaType, ids)
if err != nil {
return nil, err
}
available[mediaType] = presence
requests, err := s.store.ListActiveByTMDB(ctx, mediaType, ids)
if err != nil {
return nil, err
}
active[mediaType] = requests
}
out := &MediaPage{
Page: raw.Page,
TotalPages: raw.TotalPages,
TotalResults: raw.TotalResults,
Results: make([]MediaResult, 0, len(raw.Results)),
}
for _, item := range raw.Results {
mediaType, err := normalizeMediaType(MediaType(item.MediaType))
if err != nil || item.ID <= 0 {
continue
}
match := available[mediaType][item.ID]
activeRequest := active[mediaType][item.ID]
out.Results = append(out.Results, MediaResult{
MediaType: mediaType,
TMDBID: item.ID,
Title: item.Title,
Year: item.Year,
Overview: item.Overview,
PosterPath: item.PosterPath,
BackdropPath: item.BackdropPath,
ReleaseDate: item.ReleaseDate,
Popularity: item.Popularity,
VoteAverage: item.VoteAverage,
Availability: availabilityValue(match.Available),
LibraryContentID: match.ContentID,
Request: requestStateFor(viewer, policy, match.Available, activeRequest),
})
}
return out, nil
}
func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
if s.presence == nil {
return map[int]PresenceMatch{}, nil
}
return s.presence.Lookup(ctx, mediaType, candidates)
}
func requestPresenceCandidate(req Request) PresenceCandidate {
candidate := PresenceCandidate{
TMDBID: req.TMDBID,
IMDbID: strings.TrimSpace(req.IMDbID),
}
if req.TVDBID != nil && *req.TVDBID > 0 {
tvdbID := *req.TVDBID
candidate.TVDBID = &tvdbID
}
return candidate
}
func createPresenceCandidate(input CreateRequestInput) PresenceCandidate {
candidate := PresenceCandidate{
TMDBID: input.TMDBID,
IMDbID: strings.TrimSpace(input.IMDbID),
}
if input.TVDBID != nil && *input.TVDBID > 0 {
tvdbID := *input.TVDBID
candidate.TVDBID = &tvdbID
}
return candidate
}
func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate {
if candidate.TMDBID <= 0 {
return candidate
}
client, ok := s.tmdb.(TMDBExternalIDClient)
if !ok {
return candidate
}
externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID)
if err != nil || externalIDs == nil {
return candidate
}
if candidate.IMDbID == "" {
candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID)
}
if candidate.TVDBID == nil && externalIDs.TVDBID > 0 {
tvdbID := externalIDs.TVDBID
candidate.TVDBID = &tvdbID
}
return candidate
}
func (s *Service) hydratePresenceCandidates(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) []PresenceCandidate {
if len(candidates) == 0 {
return candidates
}
if _, ok := s.tmdb.(TMDBExternalIDClient); !ok {
return candidates
}
hydrated := append([]PresenceCandidate(nil), candidates...)
if externalIDHydrationConcurrency <= 1 {
for i := range hydrated {
if ctx.Err() != nil {
return hydrated
}
hydrated[i] = s.hydratePresenceCandidate(ctx, mediaType, hydrated[i])
}
return hydrated
}
group, groupCtx := errgroup.WithContext(ctx)
group.SetLimit(externalIDHydrationConcurrency)
for i := range hydrated {
if groupCtx.Err() != nil {
break
}
i := i
group.Go(func() error {
if err := groupCtx.Err(); err != nil {
return err
}
hydrated[i] = s.hydratePresenceCandidate(groupCtx, mediaType, hydrated[i])
return nil
})
}
_ = group.Wait()
return hydrated
}
func tmdbMediaType(mediaType MediaType) string {
if mediaType == MediaTypeSeries {
return "tv"
}
return "movie"
}
func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]PresenceMatch, error) {
if s.presence == nil {
return map[int]PresenceMatch{}, nil
}
candidates := make([]PresenceCandidate, 0, len(ids))
for _, id := range ids {
if id > 0 {
candidates = append(candidates, PresenceCandidate{TMDBID: id})
}
}
candidates = s.hydratePresenceCandidates(ctx, mediaType, candidates)
matches, err := s.lookupPresence(ctx, mediaType, candidates)
if err != nil {
return nil, err
}
return matches, nil
}
func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) {
if input == nil {
return
}
client, ok := s.tmdb.(TMDBExternalIDClient)
if !ok {
return
}
externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(input.MediaType), input.TMDBID)
if err != nil || externalIDs == nil {
return
}
if input.IMDbID == "" {
input.IMDbID = strings.TrimSpace(externalIDs.IMDbID)
}
if input.TVDBID == nil && externalIDs.TVDBID > 0 {
tvdbID := externalIDs.TVDBID
input.TVDBID = &tvdbID
}
}
func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool {
detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID)
if err != nil || detail == nil {
return false
}
return detectAnime(detail.KeywordIDs)
}
// integrationConfigured reports whether a fulfillment backend exists for the
// media type, gating auto-approval (pending vs approved). It uses the same
// router-connection selection as resolveRouterConnections — an enabled
// request_router.v1 connection with an installation — and additionally honors a
// connection's declared media-type support so a movie request only auto-approves
// when a router connection supporting "movie" exists.
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return false, err
}
for _, in := range instances {
if eligibleRouterConnection(in, mediaType) &&
strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" {
return true, nil
}
}
return false, nil
}
// integrationSupportsMediaType reports whether a router connection serves the
// given media type. An empty SupportedMediaTypes is treated as "supports all".
func integrationSupportsMediaType(in Integration, mediaType MediaType) bool {
if len(in.SupportedMediaTypes) == 0 {
return true
}
for _, mt := range in.SupportedMediaTypes {
if mt == string(mediaType) {
return true
}
}
return false
}
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) {
if req.Outcome != OutcomeActive || req.Status != StatusApproved {
return &req, nil
}
if s.router == nil {
return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured"))
}
if fc == nil {
built, err := s.newFulfillContext(ctx)
if err != nil {
return nil, err
}
fc = built
}
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
if err != nil {
return nil, err
}
if len(conns) == 0 {
// Distinguish "no backend at all" from the migration breakage where an
// existing connection row exists but its installation_id is NULL (the row
// predates the plugin install and was never re-bound).
msg := "no fulfillment backend configured"
for _, in := range fc.integrations {
if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil {
msg = "request backend connection is not bound to a plugin installation; re-save it in admin"
break
}
}
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
}
existing, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return nil, err
}
healthy := map[Quality]bool{}
for _, t := range existing {
if t.Status != StatusFailed {
healthy[t.Quality] = true
}
}
allowed := s.allowedQualities(ctx, req, fc.settings)
if !fc.settings.ForceDualQuality {
allowed = filterUnconfiguredOptionalQualities(allowed, conns)
}
var want []Quality
for _, q := range allowed {
if !healthy[q] {
want = append(want, q)
}
}
if len(want) == 0 {
return &req, nil
}
for _, t := range existing { // drop stale failed targets for the qualities we re-submit
if t.Status == StatusFailed {
for _, q := range want {
if t.Quality == q {
if err := s.store.DeleteTarget(ctx, t.ID); err != nil {
return nil, err
}
}
}
}
}
s.populateRequesterIdentity(ctx, &req)
targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, want, conns)
if err != nil {
return nil, err
}
if len(targets) == 0 {
if msg == "" {
msg = "fulfillment backend created no targets"
}
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
}
connKind := connectionKindByID(conns)
latest := &req
// The plugin is an out-of-process trust boundary: validate every returned
// target against the DB CHECK constraints (quality, status) and skip any
// quality that is duplicated in the batch or already has a healthy target, so
// a misbehaving plugin can't violate UNIQUE(request_id, quality) and wedge the
// request.
validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true}
validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true}
returned := map[Quality]bool{}
for _, rt := range targets {
if !validQuality[rt.Quality] {
slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "request_id", req.ID, "quality", string(rt.Quality))
continue
}
if returned[rt.Quality] || healthy[rt.Quality] {
continue // dup-in-batch, or a healthy target already exists for this quality
}
if rt.ConnectionID != "" {
if _, ok := connKind[rt.ConnectionID]; !ok {
slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "request_id", req.ID, "connection_id", rt.ConnectionID)
continue
}
}
returned[rt.Quality] = true
created, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: rt.ConnectionID, IntegrationKind: connKind[rt.ConnectionID],
Quality: rt.Quality, IsAnime: req.IsAnime, Status: StatusQueued,
})
if err != nil {
return nil, err
}
status := rt.Status
if status == "" || !validStatus[status] {
status = StatusQueued // coerce unknown/empty status to the DB-valid default
}
updated, err := s.store.UpdateTargetStatus(ctx, created.ID, status, rt.ExternalID, rt.ExternalStatus, rt.Message, actor)
if err != nil {
return nil, err
}
if updated != nil {
latest = updated
}
}
// Any wanted quality the plugin did not fulfill is recorded as a failed target
// rather than silently dropped, so it stays visible and Retry re-attempts it
// (a failed target is not "healthy").
const noTargetMsg = "fulfillment backend returned no target for this quality"
for _, q := range want {
if returned[q] {
continue
}
created, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, Quality: q, IsAnime: req.IsAnime, Status: StatusFailed, LastError: noTargetMsg,
})
if err != nil {
return nil, err
}
updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", noTargetMsg, actor)
if err != nil {
return nil, err
}
if updated != nil {
latest = updated
}
}
return latest, nil
}
// connectionKindByID maps each connection id to its plugin-declared service kind
// (e.g. "radarr"/"sonarr") from PluginConfig["service_kind"], for the
// integration_kind column on persisted targets. Missing kinds map to "".
func connectionKindByID(conns []ResolvedRouterConnection) map[string]string {
out := make(map[string]string, len(conns))
for _, c := range conns {
out[c.ID] = ""
if c.Config != nil {
if kind, ok := c.Config["service_kind"].(string); ok {
out[c.ID] = kind
}
}
}
return out
}
func filterUnconfiguredOptionalQualities(qualities []Quality, conns []ResolvedRouterConnection) []Quality {
out := make([]Quality, 0, len(qualities))
for _, q := range qualities {
if q == Quality2160p && !routerQualityConfigured(q, conns) {
continue
}
out = append(out, q)
}
return out
}
func routerQualityConfigured(q Quality, conns []ResolvedRouterConnection) bool {
usesTieredDefaults := false
for _, conn := range conns {
if conn.Config == nil {
continue
}
if hasRouterQualityKey(conn.Config) {
usesTieredDefaults = true
}
if q == Quality2160p && boolConfig(conn.Config, "is_default_4k") {
return true
}
}
// Generic request_router implementations may not expose arr-style HD/4K
// default flags. In that case, preserve the host's requested qualities and
// let the plugin decide what it can fulfill.
return !usesTieredDefaults
}
func hasRouterQualityKey(config map[string]any) bool {
for _, key := range []string{"is_default", "is_default_4k", "is_4k"} {
if _, ok := config[key]; ok {
return true
}
}
return false
}
func boolConfig(config map[string]any, key string) bool {
v, ok := config[key]
if !ok {
return false
}
b, ok := v.(bool)
return ok && b
}
func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) {
failed, err := s.store.SetOutcome(ctx, requestID, OutcomeFailed, actor, submitErr.Error())
if err != nil {
return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err)
}
return failed, nil
}
type reconcileChange string
const (
reconcileUnchanged reconcileChange = "unchanged"
reconcileSkipped reconcileChange = "skipped"
reconcileSubmitted reconcileChange = "submitted"
reconcileDownloading reconcileChange = "downloading"
reconcileCompleted reconcileChange = "completed"
reconcileFailed reconcileChange = "failed"
)
func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) {
completed, err := s.requestAvailable(ctx, req)
if err != nil {
return reconcileUnchanged, err
}
if completed {
// The presence check is quality-agnostic (TMDB id only), so it must not
// force-complete a request whose targets are still in flight — that would
// orphan in-progress downloads. Only take the shortcut for legacy/no-live
// -target requests; otherwise let per-target reconcile + aggregate drive
// completion.
hasLiveTargets, err := s.hasLiveTargets(ctx, req.ID)
if err != nil {
return reconcileUnchanged, err
}
if !hasLiveTargets {
if req.Status == StatusCompleted {
return reconcileUnchanged, nil
}
if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil {
return reconcileUnchanged, err
}
return reconcileCompleted, nil
}
}
if req.Status == StatusApproved {
updated, err := s.submitApprovedRequest(ctx, req, Viewer{}, fc)
if err != nil {
return reconcileUnchanged, err
}
switch {
case updated.Outcome == OutcomeFailed:
return reconcileFailed, nil
case updated.Status == StatusQueued:
return reconcileSubmitted, nil
default:
return reconcileSkipped, nil
}
}
targets, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return reconcileUnchanged, err
}
if s.router == nil {
return reconcileUnchanged, nil
}
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
if err != nil {
return reconcileUnchanged, err
}
if len(conns) == 0 {
return reconcileUnchanged, nil
}
var refs []RouterTargetRef
for _, t := range targets {
if t.Status == StatusCompleted || t.Status == StatusFailed {
continue
}
refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID})
}
if len(refs) == 0 {
return reconcileUnchanged, nil
}
statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns)
if err != nil {
return reconcileUnchanged, err
}
change := reconcileUnchanged
for _, st := range statuses {
// Match the returned status to the live target by (quality, connection).
var target *Target
for i := range targets {
if targets[i].Quality == st.Quality && targets[i].IntegrationID == st.ConnectionID {
target = &targets[i]
break
}
}
if target == nil || target.Status == StatusCompleted || target.Status == StatusFailed {
continue
}
newStatus := st.Status
if newStatus == "" || newStatus == target.Status {
continue
}
if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil {
return reconcileUnchanged, err
}
switch newStatus {
case StatusCompleted:
change = reconcileCompleted
case StatusDownloading:
if change == reconcileUnchanged {
change = reconcileDownloading
}
case StatusFailed:
if change == reconcileUnchanged {
change = reconcileFailed
}
}
}
return change, nil
}
// hasLiveTargets reports whether the request has any non-terminal (queued or
// downloading) fulfillment target.
func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) {
targets, err := s.store.ListTargets(ctx, requestID)
if err != nil {
return false, err
}
for _, t := range targets {
if t.Status == StatusQueued || t.Status == StatusDownloading {
return true, nil
}
}
return false, nil
}
func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) {
matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)})
if err != nil {
return false, err
}
return matches[req.TMDBID].Available, nil
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now().UTC()
}
func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req *Request) RequestState {
if req != nil {
state := RequestState{
Status: req.Status,
Requestable: false,
Reason: "already_requested",
}
if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID {
state.RequestID = req.ID
}
return state
}
switch {
case available:
return RequestState{Requestable: false, Reason: "already_available"}
case !policy.RequestsEnabled:
return RequestState{Requestable: false, Reason: "requests_disabled"}
case policy.Blocked:
return RequestState{Requestable: false, Reason: "blocked"}
case !policy.Unlimited && policy.Used >= policy.MaxRequests:
return RequestState{Requestable: false, Reason: "quota_exceeded"}
default:
return RequestState{Requestable: true}
}
}
func validateCreatePolicy(policy EffectivePolicy) error {
switch {
case !policy.RequestsEnabled:
return ErrRequestsDisabled
case policy.Blocked:
return ErrUserBlocked
case !policy.Unlimited && policy.Used >= policy.MaxRequests:
return QuotaError{Used: policy.Used, Limit: policy.MaxRequests, WindowDays: policy.WindowDays}
default:
return nil
}
}
func validateViewer(viewer Viewer) error {
if viewer.UserID == 0 {
return ErrForbidden
}
if strings.TrimSpace(viewer.ProfileID) == "" {
return fmt.Errorf("%w: profile is required", ErrInvalidInput)
}
return nil
}
func normalizeCreateInput(input CreateRequestInput) (CreateRequestInput, error) {
mediaType, err := normalizeMediaType(input.MediaType)
if err != nil {
return CreateRequestInput{}, err
}
input.MediaType = mediaType
input.Title = strings.TrimSpace(input.Title)
input.IMDbID = strings.TrimSpace(input.IMDbID)
input.Overview = strings.TrimSpace(input.Overview)
input.PosterPath = strings.TrimSpace(input.PosterPath)
input.BackdropPath = strings.TrimSpace(input.BackdropPath)
if input.TMDBID <= 0 {
return CreateRequestInput{}, fmt.Errorf("%w: tmdb_id is required", ErrInvalidInput)
}
if input.Title == "" {
return CreateRequestInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
return input, nil
}
func normalizeUserLimit(limit UserLimit) (UserLimit, error) {
if limit.UserID <= 0 {
return UserLimit{}, fmt.Errorf("%w: invalid user id", ErrInvalidInput)
}
switch limit.LimitMode {
case "", LimitModeInherit:
limit.LimitMode = LimitModeInherit
limit.MaxRequests = nil
limit.WindowDays = nil
case LimitModeCustom:
if limit.MaxRequests == nil || limit.WindowDays == nil || *limit.MaxRequests < 0 || *limit.WindowDays <= 0 {
return UserLimit{}, fmt.Errorf("%w: custom limits require max_requests >= 0 and window_days > 0", ErrInvalidInput)
}
case LimitModeUnlimited:
limit.MaxRequests = nil
limit.WindowDays = nil
case LimitModeBlocked:
limit.MaxRequests = nil
limit.WindowDays = nil
default:
return UserLimit{}, fmt.Errorf("%w: invalid limit mode", ErrInvalidInput)
}
switch limit.ApprovalMode {
case "", ApprovalModeInherit:
limit.ApprovalMode = ApprovalModeInherit
case ApprovalModeManual, ApprovalModeAuto, ApprovalModeBlocked:
default:
return UserLimit{}, fmt.Errorf("%w: invalid approval mode", ErrInvalidInput)
}
return limit, nil
}
func normalizeMediaType(mediaType MediaType) (MediaType, error) {
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
case MediaTypeMovie:
return MediaTypeMovie, nil
case MediaTypeSeries, "tv":
return MediaTypeSeries, nil
default:
return "", ErrInvalidMediaType
}
}
func normalizeSearchMediaType(mediaType MediaType) (MediaType, error) {
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
case "", MediaTypeAll:
return MediaTypeAll, nil
case MediaTypeMovie:
return MediaTypeMovie, nil
case MediaTypeSeries, "tv":
return MediaTypeSeries, nil
default:
return "", ErrInvalidMediaType
}
}
const (
defaultRequestListLimit = 50
maxRequestListLimit = 100
)
func normalizeListFilter(filter ListFilter) ListFilter {
if filter.Limit <= 0 {
filter.Limit = defaultRequestListLimit
}
if filter.Limit > maxRequestListLimit {
filter.Limit = maxRequestListLimit
}
if filter.Offset < 0 {
filter.Offset = 0
}
return filter
}
func availabilityValue(available bool) Availability {
if available {
return AvailabilityAvailable
}
return AvailabilityMissing
}
var discoverySectionOrder = []string{
"trending_movies",
"trending_series",
"popular_movies",
"popular_series",
"upcoming_movies",
"on_air_series",
}
var discoverySectionTitles = map[string]string{
"trending_movies": "Trending Movies",
"trending_series": "Trending Series",
"popular_movies": "Popular Movies",
"popular_series": "Popular Series",
"upcoming_movies": "Upcoming Movies",
"on_air_series": "On Air Series",
}