Files
silo-server/internal/api/router.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

3184 lines
138 KiB
Go

// Package api provides the HTTP router and middleware setup for Silo.
package api
import (
"context"
"errors"
"io/fs"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/api/handlers"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/branding"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/catalogseed"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloads"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/intromarkers"
"github.com/Silo-Server/silo-server/internal/libraryingest"
"github.com/Silo-Server/silo-server/internal/literaryworks"
"github.com/Silo-Server/silo-server/internal/logstream"
"github.com/Silo-Server/silo-server/internal/mail"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/mdblist"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
metatrakt "github.com/Silo-Server/silo-server/internal/metadata/trakt"
metadatatranslation "github.com/Silo-Server/silo-server/internal/metadata/translation"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/policy"
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/subtitles"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/Silo-Server/silo-server/internal/taskmanager/repository"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
watchtrakt "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt"
"github.com/Silo-Server/silo-server/internal/watchtogether"
"github.com/Silo-Server/silo-server/internal/webhooksync"
)
// Dependencies holds all shared dependencies that handlers need.
type Dependencies struct {
Config *config.Config
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
// worker modes); read through CurrentConfig(), which falls back to Config.
LiveConfig func() *config.Config
// OnConfigChange registers a callback fired after a live config reload
// actually changes the config. May be nil when hot reload is not wired.
OnConfigChange func(fn func(old, updated *config.Config))
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
AppContext context.Context
DB *pgxpool.Pool
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
FrontendFS fs.FS
S3Public *s3client.Client // public assets bucket client (may be nil)
S3Private *s3client.Client // private internal bucket client (may be nil)
S3UserDB *s3client.Client // user-db bucket client (may be nil)
BrandingService *branding.Service // white-label branding (nil when DB unavailable)
FolderRepo *catalog.FolderRepository // media folder repository (may be nil)
FileRepo *scanner.FileRepository // media file repository (may be nil)
Scanner *scanner.Scanner // scanner instance (may be nil)
LibraryIngester *libraryingest.Executor // shared library ingest executor (may be nil)
ProbeEnsurer handlers.PlaybackProbeEnsurer // on-demand probe repair for playback/detail (may be nil)
UserStoreProvider userstore.UserStoreProvider // user store provider (may be nil)
SessionMgr *playback.SessionManager // playback session manager (may be nil)
SkippedRootRepo *metadata.SkippedRootRepository // skipped root repository (may be nil)
StaleIDRepo *metadata.StaleMediaIDRepository // stale media ID repository (may be nil)
MovieMatchQueueRepo *metadata.MovieMatchQueueRepository
SeriesRootMatchQueueRepo *metadata.SeriesRootMatchQueueRepository
Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil)
NodeRepo *nodepool.Repository // stream node repository (may be nil)
ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil)
TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil)
NodePlanner *nodepool.Planner // group/cap-aware node selection (may be nil)
SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger
EventBus cache.EventBus
AdminStatsProvider handlers.AdminStatsSource
Recommender recommendations.Recommender // nil when disabled
RecWorker *recommendations.Worker // nil when disabled
CatalogSearchVectorizer catalog.CatalogSearchQueryVectorizer
RatingsRepo *catalog.RatingsRepo
PersonRepo *catalog.PersonRepository
PersonRefreshQueue handlers.PersonRefreshQueue
PersonRefresher handlers.PersonRefresher
RateLimitMW *ratelimit.Middleware
ClientIPResolver *clientip.Resolver
NodeID string
LogStreamHub *logstream.Hub
RealtimeHub *notifications.Hub
Notifications *notifications.System // user-facing release notifications (may be nil)
PolicySystem *policy.System // policy engine lifecycle (may be nil)
EventsHub *evt.Hub
ScanRegistry *evt.ScanRegistry
LibraryScanQueue *scanqueue.Service
ActivityLogWriter activitylog.Writer
ActivityLogRepo *activitylog.Repo
OpsLogRepo *opslog.Repo
FFmpegLogSink playback.FFmpegLogSink
RedisClient *redis.Client // for session listing (may be nil)
TaskManager *taskmanager.TaskManager // task manager (may be nil)
ArtifactManager *downloads.ArtifactManager // download prepare-to-file pipeline (may be nil)
AdminJobCancelRegistry *adminjob.CancelRegistry
IntroRepository *intromarkers.Repository
IntroAnalyzer *intromarkers.Analyzer
MarkerRegistry *markers.Registry
MarkerResolver markers.ExternalIDResolver
MarkerProviderConfig *markers.ProviderConfigStore
MarkerContributionStore *markers.ContributionStore
MarkerContributionService *markers.ContributionService
WatchProviderService handlers.WatchProviderService
WatchCompletionObserver watchstate.CompletionObserver
PluginService *plugins.Service
PluginHTTPProxy *plugins.HTTPProxy
PluginUserConfig *plugins.UserConfigStore
AuthProviders []auth.RegisteredProvider
// PublicURL is the externally-reachable origin (scheme + host) for this
// silo instance. Used to build redirect_uri values handed to OAuth
// IdPs. Empty disables the /oauth/{install_id}/{init,callback} routes.
PublicURL string
ImageResolver catalog.ImageResolver // plugin-based image URL resolver (may be nil)
PluginImageResolver *metadata.PluginImageResolver // concrete resolver for runtime source registration (may be nil)
MetadataService handlers.MatchMetadataService // metadata search+process (may be nil)
CollectionService *catalog.LibraryCollectionService // collection service (may be nil)
ChapterThumbnailQueuer catalog.ChapterThumbnailQueuer
PlaybackRealtimeHub *playback.RealtimeHub
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
RequestServerRestart func(ctx context.Context) error
ServerRestartStatus *handlers.ServerRestartStatusTracker
// UserCollectionSync handles per-profile imported collections (TMDB /
// Trakt / MDBList) — the user-facing analogue of CollectionService.
UserCollectionSync *usercollections.Service
UserCollectionScheduler *usercollections.Scheduler
// TrendingRefresher refreshes the persisted trending_discover snapshots.
// Built in main.go with TMDB wired; its Trakt fetcher is propagated here in
// router.go once the Trakt adapter exists (mirrors UserCollectionSync).
TrendingRefresher *sections.TrendingRefresher
// MDBListClient is used by user-facing list discovery endpoints
// (search/top). May be nil; the handlers report "not configured" in
// that case rather than failing.
MDBListClient *mdblist.Client
// ABSHandler is the Audiobookshelf-compatible HTTP handler. When non-nil
// it is mounted at the root router level (not under /api/v1/) so that ABS
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
}
// absHandler is the narrow interface the router needs from the ABS handler.
// Using an interface avoids a direct import of the abs sub-package from router.go.
type absHandler interface {
Mount(r chi.Router)
}
// CurrentConfig returns the live config when hot reload is wired, falling
// back to the startup snapshot otherwise.
func (d *Dependencies) CurrentConfig() *config.Config {
if d.LiveConfig != nil {
if cfg := d.LiveConfig(); cfg != nil {
return cfg
}
}
return d.Config
}
// NewRouter creates a chi.Router with all middleware and routes mounted
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
// mounted at the root level when deps.ABSHandler is non-nil.
func NewRouter(deps Dependencies) chi.Router {
r := chi.NewRouter()
// Standard middleware.
r.Use(middleware.RequestID)
// Client IP resolution must run before request logging.
if deps.ClientIPResolver != nil {
r.Use(clientip.Middleware(deps.ClientIPResolver))
}
r.Use(apimw.RequestLogger(deps.NodeID))
r.Use(middleware.Recoverer)
r.Use(apimw.Metrics)
// Compress text-like responses (JSON, SVG, …); media content types are
// not in the middleware's allowlist and stream through untouched.
r.Use(middleware.Compress(5))
// Activity logging (before auth — captures all requests including failed auth).
if deps.ActivityLogWriter != nil {
r.Use(activitylog.NewMiddleware(deps.ActivityLogWriter, deps.NodeID))
}
// Build the readiness handler with optional S3 check.
var s3Checker handlers.S3HealthChecker
if deps.S3Public != nil {
s3Checker = deps.S3Public
} else if deps.S3Private != nil {
s3Checker = deps.S3Private
}
// PG pinger: use the pool if available.
var pgPinger handlers.PGPinger
if deps.DB != nil {
pgPinger = deps.DB
}
readyHandler := handlers.NewReadyHandler(pgPinger, s3Checker)
// Resolves whether a declared profile belongs to the user and is the
// household primary profile. Nil (no user store) disables the
// acting-admin profile policy, degrading admin routes to the plain
// role check.
var checkPrimaryProfile apimw.PrimaryProfileChecker
if deps.UserStoreProvider != nil {
userStores := deps.UserStoreProvider
checkPrimaryProfile = func(ctx context.Context, userID int, profileID string) (bool, bool, error) {
store, err := userStores.ForUser(ctx, userID)
if err != nil {
return false, false, err
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil {
return false, false, err
}
if profile == nil {
return false, false, nil
}
return profile.IsPrimary, true, nil
}
}
var permissionPDP apimw.PermissionDecider
if deps.PolicySystem != nil {
permissionPDP = deps.PolicySystem.PDP()
}
// Admin authorization for routes: admin role, exercised through the
// account's primary household profile.
var requireActingAdmin func(http.Handler) http.Handler
if deps.PolicySystem != nil {
requireActingAdmin = apimw.NewPolicyActingAdminMiddleware(permissionPDP, checkPrimaryProfile)
} else {
// Legacy gate: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase.
requireActingAdmin = apimw.RequireActingAdmin(checkPrimaryProfile)
}
// Health handler advertises the server's identity so multi-server
// clients can display a friendly name. Falls back to empty strings
// if config is absent (tests, minimal fixtures); JSON omits empties.
var healthServerName, healthServerID string
if deps.Config != nil {
healthServerName = deps.Config.JellyfinCompat.ServerName
healthServerID = deps.Config.JellyfinCompat.ServerID
}
healthHandler := handlers.NewHealthHandler(healthServerName, healthServerID)
// Build server settings repo if DB is available (needed by auth and admin).
// Wrap it in the encrypting decorator so sensitive keys rest as ciphertext
// and every consumer transparently reads plaintext.
var settingsRepo catalog.SettingsStore
if deps.DB != nil {
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(deps.DB), deps.SecretCipher)
}
var accessGroupStore *access.GroupStore
if deps.DB != nil {
accessGroupStore = access.NewGroupStore(deps.DB)
}
// Build auth handler and auth middleware if DB and config are available.
var userRepo *auth.UserRepository
var inviteCodeRepo *auth.InviteCodeRepository
var apiKeyRepo *auth.APIKeyRepository
var authService *auth.Service
var authHandler *handlers.AuthHandler
var authMiddleware *apimw.AuthMiddleware
var viewerAccessMiddleware *apimw.ViewerAccessMiddleware
var metadataCurationAccess func(http.Handler) http.Handler
var markerEditAccess func(http.Handler) http.Handler
var viewerResolver apimw.ViewerResolver
var profileTokenService *access.ProfileTokenService
var jwtService *auth.JWTService
var sessionRepo *auth.SessionRepository
var deviceLoginService *auth.DeviceLoginService
if deps.DB != nil && deps.Config != nil {
userRepo = auth.NewUserRepository(deps.DB)
sessionRepo = auth.NewSessionRepository(deps.DB)
inviteCodeRepo = auth.NewInviteCodeRepository(deps.DB)
apiKeyRepo = auth.NewAPIKeyRepository(deps.DB)
jwtService = auth.NewJWTService(
deps.Config.Auth.JWTSecret,
deps.Config.Auth.AccessTokenExpiry,
deps.Config.Auth.RefreshTokenExpiry,
)
if deps.OnConfigChange != nil {
jwtForReload := jwtService
deps.OnConfigChange(func(_, updated *config.Config) {
jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
}
provider := auth.NewLocalProvider(userRepo, sessionRepo)
authService = auth.NewService(
provider,
jwtService,
sessionRepo,
userRepo,
inviteCodeRepo,
settingsRepo,
deps.UserStoreProvider,
)
for _, registration := range deps.AuthProviders {
authService.RegisterProvider(registration.Info, registration.Provider)
}
deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo)
authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService)
authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo)
profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0)
if deps.UserStoreProvider != nil {
if deps.PolicySystem != nil {
viewerResolver = policy.NewViewerResolver(userRepo, deps.UserStoreProvider, profileTokenService, deps.PolicySystem.PDP(), accessGroupStore)
} else {
// Legacy resolver: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase.
viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService, accessGroupStore)
}
viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver)
}
if deps.DB != nil {
metadataLibraries := apimw.NewPGMetadataTargetLibraryResolver(deps.DB)
if deps.PolicySystem != nil {
metadataCurationAccess = apimw.NewPolicyPermissionMiddleware(
userRepo,
metadataLibraries,
checkPrimaryProfile,
permissionPDP,
accessGroupStore,
).RequireMetadataCurationForItem
} else {
// Legacy permission middleware: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase.
metadataCurationAccess = apimw.NewPermissionMiddleware(
userRepo,
metadataLibraries,
checkPrimaryProfile,
).RequireMetadataCurationForItem
}
}
if deps.PolicySystem != nil {
markerEditAccess = apimw.NewPolicyPermissionMiddleware(
userRepo,
nil, // marker gate does not resolve target libraries
checkPrimaryProfile,
permissionPDP,
accessGroupStore,
).RequireMarkerEdit
} else {
// Legacy gate: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase.
markerEditAccess = apimw.NewPermissionMiddleware(
userRepo,
nil,
checkPrimaryProfile,
).RequireMarkerEdit
}
}
if deps.SessionMgr != nil && userRepo != nil {
deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) {
user, err := userRepo.GetByID(ctx, userID)
if err != nil {
return playback.SessionLimits{}, err
}
effective, err := access.EffectivePolicyForUser(ctx, user, accessGroupStore)
if err != nil {
return playback.SessionLimits{}, err
}
return playback.SessionLimits{
MaxStreams: effective.MaxStreams,
MaxTranscodes: effective.MaxTranscodes,
}, nil
})
if deps.PolicySystem != nil {
deps.SessionMgr.SetAdmissionDecider(policy.NewPlaybackAdmissionDecider(deps.PolicySystem.PDP()))
}
}
// Build demo guard middleware if server settings are available.
var demoGuard *apimw.DemoGuard
if settingsRepo != nil {
demoGuard = apimw.NewDemoGuard(settingsRepo)
}
// Build library handler if folder repo is available.
var libraryHandler *handlers.LibraryHandler
if deps.FolderRepo != nil {
libraryHandler = handlers.NewLibraryHandler(deps.FolderRepo, deps.LibraryIngester, userRepo, deps.DB, deps.Refresher, deps.AppContext)
libraryHandler.EventBus = deps.EventBus
libraryHandler.EventsHub = deps.EventsHub
libraryHandler.ScanRegistry = deps.ScanRegistry
libraryHandler.ScanQueue = deps.LibraryScanQueue
libraryHandler.MovieMatchQueueRepo = deps.MovieMatchQueueRepo
libraryHandler.SeriesMatchQueueRepo = deps.SeriesRootMatchQueueRepo
libraryHandler.RawMatchBacklogRepo = deps.FileRepo
if deps.Config != nil {
libraryHandler.TVSeriesRootQueue = deps.Config.Matcher.TVSeriesRootQueueEnabled()
}
if deps.DB != nil {
libraryHandler.JobRepo = adminjob.NewRepository(deps.DB)
}
// Library poster uploads are writable client-facing assets, so they
// belong in the public assets bucket.
if deps.S3Public != nil {
libraryHandler.S3Meta = deps.S3Public
}
// Wire provider chain repos for per-library provider priority management.
if deps.DB != nil && deps.PluginService != nil {
libraryHandler.ChainRepo = metadata.NewChainRepository(deps.DB)
libraryHandler.PluginInstallations = plugins.NewInstallationStore(deps.DB)
}
if invalidator, ok := deps.MetadataService.(interface{ InvalidateChainCache() }); ok {
libraryHandler.SetChainCacheInvalidator(invalidator)
}
if deps.SkippedRootRepo != nil {
libraryHandler.SkippedRootRepo = deps.SkippedRootRepo
}
if deps.StaleIDRepo != nil {
libraryHandler.StaleIDRepo = deps.StaleIDRepo
}
if deps.DB != nil {
libraryHandler.SectionRepo = sections.NewRepository(deps.DB)
}
if deps.UserStoreProvider != nil {
libraryHandler.StoreProvider = deps.UserStoreProvider
}
}
// Build ratings repo if DB is available. Use dep-injected repo when provided
// (e.g. already constructed in main.go for the recommendations engine).
var ratingsRepo *catalog.RatingsRepo
if deps.RatingsRepo != nil {
ratingsRepo = deps.RatingsRepo
} else if deps.DB != nil {
ratingsRepo = catalog.NewRatingsRepo(deps.DB)
}
// Build browse/search/items handlers if DB is available.
var itemsHandler *handlers.ItemsHandler
var catalogResourceHandler *handlers.CatalogResourceHandler
var catalogHandler *handlers.CatalogHandler
var literaryWorkHandler *handlers.LiteraryWorkHandler
var peopleHandler *handlers.PeopleHandler
var itemRepo *catalog.ItemRepository
var episodeRepo *catalog.EpisodeRepository
var providerIDRepo *catalog.ProviderIDRepository
var seasonRepo *catalog.SeasonRepository
var detailSvc *catalog.DetailService
var calendarRepo *catalog.CalendarRepository
var catalogSearchService *catalog.CatalogSearchService
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
var autoscanHandler *handlers.AutoscanHandler
var ebookReaderHandler *handlers.EbookReaderHandler
var ebookProgressStore *handlers.PGEbookReaderProgressStore
var ebookConfigStore *handlers.PGEbookReaderConfigStore
var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore
if deps.DB != nil {
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB)
ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB)
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
searchIndexEvents := catalog.NewSearchIndexEventRepository(deps.DB)
catalogSearchService = catalog.NewCatalogSearchService(
context.Background(),
settingsRepo,
itemRepo,
searchIndexEvents,
deps.CatalogSearchVectorizer,
)
if catalogSearchService != nil {
catalogSearchService.StartCoverageRefresh(deps.AppContext)
}
activeSearchProvider := catalog.SearchProviderPostgres
if _, ok := catalogSearchService.Provider().(*catalog.MeilisearchSearchProvider); ok {
activeSearchProvider = catalog.SearchProviderMeilisearch
}
searchIndexEvents.WithActiveProvider(activeSearchProvider)
// Latch the provider for the package-level enqueue helpers used by
// metadata/scanner/etc. so they skip the per-call settings lookup.
catalog.SetActiveSearchIndexProvider(activeSearchProvider)
itemRepo.WithSearchIndexEvents(searchIndexEvents)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
providerIDRepo = catalog.NewProviderIDRepository(deps.DB)
calendarRepo = catalog.NewCalendarRepository(deps.DB)
var fileFetcher catalog.FileVersionFetcher
if deps.FileRepo != nil {
fileFetcher = deps.FileRepo
}
seasonRepo = catalog.NewSeasonRepository(deps.DB)
folderRepo := catalog.NewFolderRepository(deps.DB)
var episodeFileProvider handlers.EpisodeFileProvider
if deps.FileRepo != nil {
episodeFileProvider = deps.FileRepo
}
rootClaimRepo := catalog.NewRootClaimRepository(deps.DB)
groupClaimRepo := catalog.NewGroupClaimRepository(deps.DB)
literaryRepo := literaryworks.NewRepository(deps.DB)
literaryWorkHandler = &handlers.LiteraryWorkHandler{Service: literaryworks.NewService(literaryRepo)}
detailSvc = catalog.NewDetailService(itemRepo, episodeRepo, seasonRepo, deps.PersonRepo, fileFetcher)
detailSvc.SetFolderRepository(folderRepo)
detailSvc.SetRootClaimRepository(rootClaimRepo)
detailSvc.SetGroupClaimRepository(groupClaimRepo)
detailSvc.SetWorkSummaryProvider(literaryRepo)
detailSvc.SetProbeEnsurer(deps.ProbeEnsurer)
detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer)
if deps.ImageResolver != nil {
detailSvc.SetImageResolver(deps.ImageResolver)
}
detailSvc.SetUserStoreProvider(deps.UserStoreProvider)
itemsHandler = handlers.NewItemsHandler(
browseRepo,
itemRepo,
episodeRepo,
seasonRepo,
ratingsRepo,
episodeFileProvider,
deps.UserStoreProvider,
detailSvc,
providerIDRepo,
)
if catalogSearchService != nil {
itemsHandler.SetCatalogSearchProvider(catalogSearchService.Provider())
}
itemsHandler.EventsHub = deps.EventsHub
itemsHandler.UserRepo = userRepo
if requester, ok := deps.MetadataService.(handlers.MetadataRefreshRequester); ok {
itemsHandler.SetMetadataRefreshRequester(requester)
}
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
}
if deps.WatchCompletionObserver != nil {
itemsHandler.SetCompletionObserver(deps.WatchCompletionObserver)
}
if ebookProgressStore != nil {
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
if deps.FileRepo != nil {
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
})
if ebookProgressStore != nil {
ebookReaderHandler.ProgressStore = ebookProgressStore
}
if ebookConfigStore != nil {
ebookReaderHandler.ConfigStore = ebookConfigStore
}
if ebookAnnotationStore != nil {
ebookReaderHandler.AnnotationStore = ebookAnnotationStore
}
if conv := buildEbookConversion(deps, settingsRepo); conv != nil {
ebookReaderHandler.Conversion = conv
}
}
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
catalogHandler = handlers.NewCatalogHandler(
catalog.NewCatalogResolver(browseRepo, itemRepo).
WithEpisodeRepository(episodeRepo).
WithUserStoreProvider(deps.UserStoreProvider).
WithSearchProvider(catalogSearchService.Provider()),
itemsHandler,
)
catalogHandler.SetWorkSummaryProvider(literaryRepo)
tmdbAPIKey := ""
if deps.Config != nil {
tmdbAPIKey = deps.Config.TMDBAPIKey
}
requestsRepo := mediarequests.NewRepository(deps.DB, deps.SecretCipher)
requestSvc := mediarequests.NewService(
requestsRepo,
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetGroupPolicyProvider(accessGroupStore)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(scopeEntitlementResolver{resolver: viewerResolver})
}
// Request lifecycle notifications (submitted / approved / declined):
// server-channel broadcasts plus personal deliveries to the requester
// on approve/decline. Fulfilled rides the reconcile service's
// fulfillment notifier instead.
if lifecycle := notifications.NewRequestLifecycleNotifier(deps.Notifications); lifecycle != nil {
requestSvc.SetLifecycleNotifier(lifecycle)
}
requestHandler = handlers.NewRequestsHandler(requestSvc)
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil {
autoscanSvc := BuildAutoscanService(
autoscanRepo,
deps.PluginService,
plugins.NewInstallationStore(deps.DB),
requestsRepo,
deps.FolderRepo,
deps.LibraryScanQueue,
deps.RedisClient,
)
autoscanHandler = handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc)
// Wire the optional poll-task rescheduler so a settings change
// re-applies the poll interval without a restart.
if deps.TaskManager != nil {
autoscanHandler.SetTriggerUpdater(deps.TaskManager)
}
}
if deps.PersonRepo != nil {
peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc)
peopleHandler.SetItemsHandler(itemsHandler)
peopleHandler.SetRefreshQueue(deps.PersonRefreshQueue)
peopleHandler.SetRefreshService(deps.PersonRefresher)
}
}
// Build profile/personal data handlers if UserStoreProvider is available.
var profileHandler *handlers.ProfileHandler
var personalDataHandler *handlers.PersonalDataHandler
var progressHandler *handlers.ProgressHandler
var collectionHandler *handlers.CollectionHandler
var settingsHandler *handlers.SettingsHandler
var homeDismissalHandler *handlers.HomeDismissalHandler
var subtitlePrefHandler *handlers.SubtitlePrefHandler
var audioPrefHandler *handlers.AudioPrefHandler
var libraryPlaybackPrefHandler *handlers.LibraryPlaybackPrefHandler
var watchProviderHandler *handlers.WatchProviderHandler
var playbackSessionsLoader *handlers.PlaybackSessionsLoader
if deps.DB != nil {
playbackSessionsLoader = handlers.NewPlaybackSessionsLoader(deps.DB, deps.UserStoreProvider, detailSvc)
}
if deps.UserStoreProvider != nil {
profileHandler = handlers.NewProfileHandler(deps.UserStoreProvider)
profileHandler.UserRepo = userRepo
profileHandler.ProfileTokens = profileTokenService
profileHandler.AvatarStore = deps.S3Private
profileHandler.SessionsReader = playbackSessionsLoader
personalDataHandler = handlers.NewPersonalDataHandler(deps.UserStoreProvider, itemRepo)
if detailSvc != nil {
personalDataHandler.SetDetailService(detailSvc)
}
if ebookProgressStore != nil {
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
personalDataHandler.SetEpisodeRepo(episodeRepo)
personalDataHandler.SetSeasonRepo(seasonRepo)
personalDataHandler.EventsHub = deps.EventsHub
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalListEventDispatcher); ok {
personalDataHandler.SetLocalListEventDispatcher(dispatcher)
}
progressHandler = handlers.NewProgressHandler(deps.UserStoreProvider)
progressHandler.EventsHub = deps.EventsHub
if settingsRepo != nil {
progressHandler.SettingsRepo = settingsRepo
}
if deps.DB != nil {
progressHandler.LibraryLookup = catalog.NewLibraryItemRepository(deps.DB)
}
collectionHandler = handlers.NewCollectionHandler(deps.UserStoreProvider)
if deps.DB != nil {
collectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB}
}
if deps.S3Public != nil {
collectionHandler.S3GP = deps.S3Public
collectionHandler.PresignTTL = 4 * time.Hour
}
settingsHandler = handlers.NewSettingsHandler(deps.UserStoreProvider)
if settingsRepo != nil {
settingsHandler.SetServerSettings(settingsRepo)
}
homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider)
homeDismissalHandler.EventsHub = deps.EventsHub
subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider)
audioPrefHandler = handlers.NewAudioPrefHandler(deps.UserStoreProvider)
libraryPlaybackPrefHandler = handlers.NewLibraryPlaybackPrefHandler(deps.UserStoreProvider)
if deps.FolderRepo != nil {
libraryPlaybackPrefHandler.SetLibraryLookup(deps.FolderRepo)
} else if deps.DB != nil {
libraryPlaybackPrefHandler.SetLibraryLookup(catalog.NewFolderRepository(deps.DB))
}
}
if deps.WatchProviderService != nil {
watchProviderHandler = handlers.NewWatchProviderHandler(deps.WatchProviderService)
}
// Build ratings handler if both repo and itemRepo are available.
var ratingsHandler *handlers.RatingsHandler
var recsRepoForStale *recommendations.Repo
if ratingsRepo != nil && itemRepo != nil {
ratingsHandler = handlers.NewRatingsHandler(ratingsRepo, itemRepo)
if deps.DB != nil {
recsRepoForStale = recommendations.NewRepo(deps.DB)
ratingsHandler.SetProfileStaler(recsRepoForStale)
ratingsHandler.SetProfileRefreshRequester(deps.RecWorker)
if personalDataHandler != nil {
personalDataHandler.SetProfileStaler(recsRepoForStale)
personalDataHandler.SetProfileRefreshRequester(deps.RecWorker)
}
if progressHandler != nil {
progressHandler.SetProfileStaler(recsRepoForStale)
progressHandler.SetProfileRefreshRequester(deps.RecWorker)
}
if itemsHandler != nil {
itemsHandler.SetProfileStaler(recsRepoForStale)
itemsHandler.SetProfileRefreshRequester(deps.RecWorker)
}
}
}
// Create subtitleRepo early — only needs DB, shared with playback handler and subtitle search handler.
var subtitleRepo *subtitles.PgRepository
if deps.DB != nil {
subtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
}
// Notifier that pushes "subtitle ready" events to active sessions when an AI
// translation completes. Assigned inside the playback handler block where the
// realtime hub and session manager are in scope; nil when playback is off.
var subtitleAINotifier *playback.SubtitleReadyNotifier
// Build playback handler if session manager is available.
var playbackHandler *handlers.PlaybackHandler
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
var playbackCommandDispatcher *playback.CommandDispatcher
var streamHandler *handlers.StreamHandler
var watchTogetherHandler *handlers.WatchTogetherHandler
if deps.SessionMgr != nil {
var playbackAdminStore handlers.PlaybackAdminStore
if deps.DB != nil {
playbackAdminStore = handlers.NewPGPlaybackAdminStore(deps.DB, deps.EventsHub)
}
if deps.FileRepo != nil {
playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr, deps.FileRepo)
streamHandler = handlers.NewStreamHandler(deps.SessionMgr, deps.FileRepo)
} else {
playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr)
}
// Wire UserStoreProvider for progress/history persistence.
if deps.UserStoreProvider != nil {
playbackHandler.StoreProvider = deps.UserStoreProvider
}
playbackHandler.StableIdentityResolver = watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)
playbackHandler.CompletionObserver = deps.WatchCompletionObserver
if scrobbler, ok := deps.WatchProviderService.(handlers.PlaybackWatchScrobbler); ok {
playbackHandler.WatchScrobbler = scrobbler
}
playbackHandler.AdminStore = playbackAdminStore
playbackHandler.EventsHub = deps.EventsHub
if deps.FileRepo != nil {
playbackHandler.MissingMarker = deps.FileRepo
}
if deps.SessionSyncer != nil {
playbackHandler.SessionSyncer = deps.SessionSyncer
}
if streamHandler != nil {
// Share the playback handler's transcode/reconstruct manager so a
// direct/remux stream can rebuild its session from the token recipe
// after a restart (same manager, same SessionManager).
streamHandler.TM = playbackHandler.TranscodeManager()
if deps.Config != nil {
streamHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
streamHandler.AdminStore = playbackAdminStore
streamHandler.EventsHub = deps.EventsHub
streamHandler.SessionSyncer = deps.SessionSyncer
if deps.FileRepo != nil {
streamHandler.MissingMarker = deps.FileRepo
}
}
// Wire the optional node planner and JWT secret for node-aware stream URLs.
if deps.NodePlanner != nil {
playbackHandler.NodePlanner = deps.NodePlanner
}
if deps.Config != nil && deps.Config.Auth.JWTSecret != "" {
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.Config != nil {
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
}
}
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
playbackHandler.ChapterThumbnailQueuer = deps.ChapterThumbnailQueuer
if settingsRepo != nil {
playbackHandler.SettingsRepo = settingsRepo
}
if deps.FileRepo != nil {
playbackHandler.FileVersionFetcher = deps.FileRepo
}
if subtitleRepo != nil {
playbackHandler.SubtitleRepo = subtitleRepo
}
if recsRepoForStale != nil {
playbackHandler.SetProfileStaler(recsRepoForStale)
playbackHandler.SetProfileRefreshRequester(deps.RecWorker)
}
realtimeHub := deps.PlaybackRealtimeHub
if realtimeHub == nil {
realtimeHub = playback.NewRealtimeHub()
}
commandTracker := playback.NewCommandTracker()
playbackHandler.RealtimeHub = realtimeHub
playbackHandler.CommandTracker = commandTracker
playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker)
playbackCommandDispatcher = playbackHandler.CommandDispatcher
playbackHandler.IntroAnalyzer = deps.IntroAnalyzer
playbackHandler.IntroRepository = deps.IntroRepository
playbackHandler.MarkerRegistry = deps.MarkerRegistry
playbackHandler.MarkerResolver = deps.MarkerResolver
if deps.FileRepo != nil {
playbackHandler.MarkerUpserter = deps.FileRepo
}
playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub)
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub)
adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler)
if deps.DB != nil && deps.FileRepo != nil && viewerResolver != nil && deps.Config != nil && detailSvc != nil {
roomTokenService := watchtogether.NewRoomTokenService(deps.Config.Auth.JWTSecret, 24*time.Hour)
watchTogetherHandler = handlers.NewWatchTogetherHandler(
watchtogether.NewService(
watchtogether.NewRepository(deps.DB),
deps.SessionMgr,
deps.FileRepo,
watchtogether.NewCatalogSelectionResolver(detailSvc),
watchtogether.NewSuggestionRepository(deps.DB),
watchtogether.NewProfileNameResolver(deps.UserStoreProvider),
),
viewerResolver,
roomTokenService,
)
}
}
// Wire subtitle repo and S3 client onto streamHandler for S3-stored subtitle serving.
if streamHandler != nil && subtitleRepo != nil && deps.S3Public != nil {
streamHandler.SubtitleRepo = subtitleRepo
streamHandler.S3Client = deps.S3Public
streamHandler.S3Bucket = deps.S3Public.Bucket()
}
if streamHandler != nil && deps.Config != nil {
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
}
restartStatus := deps.ServerRestartStatus
if restartStatus == nil {
restartStatus = handlers.NewServerRestartStatusTracker()
}
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher, restartStatus)
// Build admin handler if we have a user repo.
var adminHandler *handlers.AdminHandler
var accessGroupHandler *handlers.AccessGroupHandler
var catalogSeedHandler *handlers.CatalogSeedHandler
var adminJobsHandler *handlers.AdminJobsHandler
if userRepo != nil {
adminHandler = handlers.NewAdminHandler(userRepo, deps.DB, deps.UserStoreProvider)
adminHandler.SessionsLoader = playbackSessionsLoader
adminHandler.DetailSvc = detailSvc
adminHandler.EventBus = deps.EventBus
adminHandler.EventsHub = deps.EventsHub
adminHandler.ImpersonationService = authService
adminHandler.StatsSource = deps.AdminStatsProvider
adminHandler.RealtimeHub = deps.RealtimeHub
adminHandler.AccessGroups = accessGroupStore
adminHandler.BootstrapSensitiveConfigured = deps.BootstrapSensitiveConfigured
adminHandler.BootstrapSensitiveValues = deps.BootstrapSensitiveValues
adminHandler.RestartStatus = restartStatus
adminHandler.CatalogSearchStatus = catalogSearchService
if settingsRepo != nil {
adminHandler.SettingsRepo = settingsRepo
}
adminHandler.Config = deps.Config
if deps.OnUserSessionsRevoked != nil {
adminHandler.OnUserSessionsRevoked = deps.OnUserSessionsRevoked
}
if deps.OnServerSettingUpdated != nil {
adminHandler.OnServerSettingUpdated = deps.OnServerSettingUpdated
}
}
if accessGroupStore != nil {
accessGroupHandler = handlers.NewAccessGroupHandler(accessGroupStore)
}
if deps.DB != nil {
jobRepo := adminjob.NewRepository(deps.DB)
// Avoid wrapping a nil *s3client.Client in a non-nil interface;
// handlers rely on interface-nil checks to gate S3 features.
var privateStore handlers.CatalogSeedArtifactStore
if deps.S3Private != nil {
privateStore = deps.S3Private
}
catalogSeedHandler = handlers.NewCatalogSeedHandler(catalogseed.NewService(deps.DB, deps.PersonRepo, recommendations.NewRepo(deps.DB)), jobRepo, privateStore)
catalogSeedHandler.RealtimeHub = deps.RealtimeHub
adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore)
adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry
adminJobsHandler.RealtimeHub = deps.RealtimeHub
if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil {
adminHandler.JobRepo = jobRepo
adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver(
itemRepo,
catalog.NewSeasonRepository(deps.DB),
episodeRepo,
deps.FolderRepo,
deps.FileRepo,
)
}
}
// Build admin match handler if metadata service and item repo are available.
var adminMatchHandler *handlers.AdminMatchHandler
if deps.MetadataService != nil && itemRepo != nil && deps.DB != nil {
adminMatchHandler = handlers.NewAdminMatchHandler(
itemRepo,
&handlers.PoolFolderLookup{Pool: deps.DB},
deps.MetadataService,
)
}
// Build admin image handler for poster/backdrop/logo selection.
var adminImageHandler *handlers.AdminImageHandler
if imageSvc, ok := deps.MetadataService.(handlers.ImageService); ok && itemRepo != nil && seasonRepo != nil && episodeRepo != nil && deps.DB != nil && detailSvc != nil {
adminImageHandler = handlers.NewAdminImageHandler(
itemRepo,
seasonRepo,
episodeRepo,
&handlers.PoolFolderLookup{Pool: deps.DB},
imageSvc,
deps.PluginImageResolver,
detailSvc,
)
}
var adminIntroHandler *handlers.AdminIntroHandler
if deps.IntroAnalyzer != nil && deps.IntroRepository != nil {
adminIntroHandler = handlers.NewAdminIntroHandler(
deps.IntroAnalyzer,
deps.IntroRepository,
deps.AppContext,
slog.Default(),
)
adminIntroHandler.Settings = settingsRepo
adminIntroHandler.FileResolver = deps.FileRepo
if playbackHandler != nil {
adminIntroHandler.MarkerUpdateNotifier = playbackHandler.MarkerUpdateNotifier
}
}
var markersHandler *handlers.MarkersHandler
if deps.FileRepo != nil {
var notifier handlers.PlaybackMarkerUpdateNotifier
if playbackHandler != nil {
notifier = playbackHandler.MarkerUpdateNotifier
}
var contributor handlers.MarkerContributor
if deps.MarkerContributionService != nil {
contributor = deps.MarkerContributionService
}
var contributions handlers.MarkerContributionLister
if deps.MarkerContributionStore != nil {
contributions = deps.MarkerContributionStore
}
markersHandler = handlers.NewMarkersHandler(
deps.FileRepo, deps.FileRepo, contributor, contributions, notifier, slog.Default(),
)
markersHandler.BaseContext = deps.AppContext
markersHandler.AuditHistory = deps.FileRepo
if itemRepo != nil {
markersHandler.Authorizer = &handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
}
}
}
var adminMarkerProvidersHandler *handlers.AdminMarkerProvidersHandler
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
adminMarkerProvidersHandler = handlers.NewAdminMarkerProvidersHandler(
deps.MarkerRegistry, deps.MarkerProviderConfig, deps.EventBus, slog.Default(),
)
}
// Admin subtitle config handler only needs the DB repo — no S3 required.
var adminSubtitleHandler *handlers.AdminSubtitleHandler
var subtitleManager *subtitles.Manager
if subtitleRepo != nil {
adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo)
}
// Build subtitle search handler if we have DB and S3.
var subtitleSearchHandler *handlers.SubtitleSearchHandler
if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil {
subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket())
// Load provider configs from DB and register enabled providers.
providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext)
for _, cfg := range providerConfigs {
if !cfg.Enabled {
continue
}
switch cfg.ProviderName {
case "opensubtitles":
if cfg.Username == "" || cfg.Password == "" {
continue
}
subtitleManager.RegisterProvider(opensubtitles.New(opensubtitles.Config{
Username: cfg.Username,
Password: cfg.Password,
}))
case "subdl":
if cfg.APIKey == "" {
continue
}
subtitleManager.RegisterProvider(subdl.New(subdl.Config{APIKey: cfg.APIKey}))
case "subsource":
if cfg.APIKey == "" {
continue
}
subtitleManager.RegisterProvider(subsource.New(subsource.Config{APIKey: cfg.APIKey}))
}
}
mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB}
subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver)
}
if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil {
adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager)
}
// Build the AI subtitle handler (on-demand translation). Generated tracks are
// stored as ordinary downloaded subtitles, so they reach every client through
// the existing subtitle pipeline with no client changes.
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
// and metadata translation draw from one client and one concurrency bound.
// Connection settings, models, toggles, and quotas hot-reload through
// OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is
// fixed at construction.
var aiClient *llm.Client
var aiSem chan struct{}
if deps.Config != nil {
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
if deps.OnConfigChange != nil {
clientForReload := aiClient
deps.OnConfigChange(func(_, updated *config.Config) {
clientForReload.UpdateConfig(llmConfigFromServer(updated))
})
}
}
var subtitleAIHandler *handlers.SubtitleAIHandler
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
if disabledGateway != "" {
warnChatOnlyGateway(disabledGateway)
}
var aiNotifier subtitleai.Notifier
if subtitleAINotifier != nil {
aiNotifier = subtitleAINotifier
}
aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors)
aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds)
aiService := subtitleai.NewService(
deps.AppContext,
aiCfg,
subtitleai.NewPgJobRepository(deps.DB),
aiTranslator,
aiTranscriber,
subtitleManager,
subtitleRepo,
deps.FileRepo,
aiNotifier,
deps.Config.Playback.FFmpegPath,
slog.Default(),
aiSem,
)
aiService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(old, updated *config.Config) {
newCfg, newDisabled := effectiveSubtitleAIConfig(updated)
aiService.UpdateConfig(newCfg)
aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors)
aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds)
// Warn only when the gateway-disable condition newly appears,
// not on every unrelated settings change.
if newDisabled != "" && old != nil {
if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" {
warnChatOnlyGateway(newDisabled)
}
}
})
}
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
}
// Metadata AI translation (descriptions into the localization tables).
var metadataAIHandler *handlers.MetadataAIHandler
if deps.DB != nil && deps.Config != nil && aiClient != nil {
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
mtService := metadatatranslation.NewService(
deps.AppContext,
metadataAIConfigFromServer(deps.Config),
mtRepo,
mtRepo,
&metadatatranslation.CatalogLocalizationStore{
Items: catalog.NewMediaItemLocalizationRepository(deps.DB),
Seasons: catalog.NewSeasonLocalizationRepository(deps.DB),
Episodes: catalog.NewEpisodeLocalizationRepository(deps.DB),
},
aiClient.SystemUserChat,
aiSem,
slog.Default(),
)
mtService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(_, updated *config.Config) {
mtService.UpdateConfig(metadataAIConfigFromServer(updated))
})
}
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
// Wire the refresh fallback: libraries with auto_translate_metadata get
// missing localizations filled after each metadata refresh.
if mt, ok := deps.MetadataService.(interface {
SetAutoTranslator(metadata.AutoTranslator)
}); ok {
mt.SetAutoTranslator(mtService)
}
}
// Build section handler if DB is available.
var sectionHandler *handlers.SectionHandler
var sectionSettingsHandler *handlers.SectionSettingsHandler
var sectionBulkHandler *handlers.SectionBulkHandler
var libraryCollectionHandler *handlers.LibraryCollectionHandler
var libraryCollectionGroupHandler *handlers.LibraryCollectionGroupHandler
libraryCollectionService := deps.CollectionService
if deps.DB != nil {
sectionRepo := sections.NewRepository(deps.DB)
sectionBulkHandler = &handlers.SectionBulkHandler{Repo: sectionRepo}
sectionFetcher := sections.NewFetcher(deps.DB)
sectionFetcher.StoreProvider = deps.UserStoreProvider
sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB)
sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
if deps.DB != nil {
sectionFetcher.RecommendationRepo = recommendations.NewRepo(deps.DB)
if ratingsRepo != nil {
sectionFetcher.RecommendationReader = recommendations.NewReader(sectionFetcher.RecommendationRepo, ratingsRepo, deps.RecWorker, deps.UserStoreProvider)
}
}
sections.InstallRecipeDelegate(sectionFetcher)
sectionHandler = handlers.NewSectionHandler(sectionRepo, sectionFetcher)
sectionHandler.CollectionRepo = sectionFetcher.CollectionRepo
sectionHandler.FolderRepo = deps.FolderRepo
if deps.UserStoreProvider != nil {
sectionHandler.StoreProvider = deps.UserStoreProvider
}
sectionHandler.EpisodeRepo = episodeRepo
sectionHandler.DetailSvc = detailSvc
if ebookProgressStore != nil {
sectionHandler.EbookProgress = ebookProgressStore
}
if userRepo != nil {
sectionHandler.UserRepo = userRepo
}
if settingsRepo != nil {
sectionHandler.Settings = settingsRepo
sectionSettingsHandler = &handlers.SectionSettingsHandler{Settings: settingsRepo}
}
libraryCollectionRepo := catalog.NewLibraryCollectionRepository(deps.DB)
if libraryCollectionService == nil {
libraryCollectionService = catalog.NewLibraryCollectionService(
libraryCollectionRepo,
itemRepo,
catalog.NewLibraryItemRepository(deps.DB),
nil,
)
}
if libraryCollectionService.TMDBCollections == nil {
apiKey := ""
if deps.Config != nil {
apiKey = deps.Config.TMDBAPIKey
}
libraryCollectionService.TMDBCollections = &tmdbCollectionAdapter{
client: tmdb.NewClient(apiKey, 40),
}
}
if libraryCollectionService.TMDBFranchises == nil {
apiKey := ""
if deps.Config != nil {
apiKey = deps.Config.TMDBAPIKey
}
libraryCollectionService.TMDBFranchises = &tmdbFranchiseAdapter{
client: tmdb.NewClient(apiKey, 40),
}
}
if libraryCollectionService.TMDBDiscovers == nil {
apiKey := ""
if deps.Config != nil {
apiKey = deps.Config.TMDBAPIKey
}
libraryCollectionService.TMDBDiscovers = &tmdbDiscoverAdapter{
client: tmdb.NewClient(apiKey, 40),
}
}
traktClientID := ""
if settingsRepo != nil {
ctx := deps.AppContext
if ctx == nil {
ctx = context.Background()
}
if value, err := settingsRepo.Get(ctx, "watchsync.trakt.client_id"); err == nil {
traktClientID = value
}
}
if libraryCollectionService.TraktCollections == nil {
libraryCollectionService.TraktCollections = &traktCollectionAdapter{
client: metatrakt.NewClient(traktClientID, 5),
}
}
if libraryCollectionService.TraktTokenResolver == nil && deps.DB != nil && settingsRepo != nil {
libraryCollectionService.TraktTokenResolver = &traktCollectionTokenResolver{
pool: deps.DB,
settings: settingsRepo,
cipher: deps.SecretCipher,
provider: watchtrakt.NewProvider(nil, ""),
}
}
// Propagate the now-wired Trakt + TMDB fetchers to the user-side sync
// service (constructed earlier in main.go before settingsRepo and the
// Trakt adapters existed, so its fetcher fields started nil).
if deps.UserCollectionSync != nil {
if deps.UserCollectionSync.TraktCollections == nil {
deps.UserCollectionSync.TraktCollections = libraryCollectionService.TraktCollections
}
if deps.UserCollectionSync.TraktTokenResolver == nil {
deps.UserCollectionSync.TraktTokenResolver = libraryCollectionService.TraktTokenResolver
}
if deps.UserCollectionSync.TMDBCollections == nil {
deps.UserCollectionSync.TMDBCollections = libraryCollectionService.TMDBCollections
}
}
// Propagate the now-wired Trakt fetcher to the trending refresher (built
// in main.go with TMDB only, before the Trakt adapter existed).
if deps.TrendingRefresher != nil && deps.TrendingRefresher.TraktTrending == nil {
deps.TrendingRefresher.TraktTrending = libraryCollectionService.TraktCollections
}
// Wire the trending snapshot reader into the section fetcher. The
// trending_discover home section reads its list from the persisted
// snapshot table; the upstream fetch happens out-of-band in the refresh
// task, so the read path never calls the provider.
sectionFetcher.TrendingSnapshots = sections.NewTrendingSnapshotRepository(deps.DB)
libraryCollectionHandler = handlers.NewLibraryCollectionHandler(
libraryCollectionRepo,
libraryCollectionService,
itemRepo,
4*time.Hour,
nil,
deps.S3Public,
)
libraryCollectionHandler.FrontendFS = deps.FrontendFS
libraryCollectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB}
libraryCollectionHandler.SectionRepo = sectionRepo
libraryCollectionHandler.UserCollectionPool = deps.DB
libraryCollectionHandler.EventsHub = deps.EventsHub
if deps.FolderRepo != nil {
libraryCollectionHandler.FolderRepo = deps.FolderRepo
} else {
libraryCollectionHandler.FolderRepo = catalog.NewFolderRepository(deps.DB)
}
libraryCollectionGroupRepo := catalog.NewLibraryCollectionGroupRepository(deps.DB)
libraryCollectionHandler.GroupRepo = libraryCollectionGroupRepo
if deps.DB != nil {
libraryCollectionHandler.JobRepo = adminjob.NewRepository(deps.DB)
}
libraryCollectionGroupHandler = handlers.NewLibraryCollectionGroupHandler(
libraryCollectionGroupRepo,
libraryCollectionRepo,
deps.DB,
)
refresher := &catalog.SmartCountRefresher{
Pool: deps.DB,
Executor: &catalog.QueryExecutor{Pool: deps.DB},
}
libraryCollectionHandler.SmartCountRefresher = refresher
appCtx := deps.AppContext
if appCtx == nil {
appCtx = context.Background()
}
go func() {
select {
case <-time.After(15 * time.Second):
case <-appCtx.Done():
return
}
refreshed, errs := refresher.RefreshAll(appCtx)
slog.Info("smart-count refresh complete", "refreshed", refreshed, "errors", errs)
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-appCtx.Done():
return
case <-ticker.C:
refreshed, errs := refresher.RefreshAll(appCtx)
slog.Debug("smart-count refresh complete", "refreshed", refreshed, "errors", errs)
}
}
}()
if detailSvc != nil {
libraryCollectionHandler.SetDetailService(detailSvc)
libraryCollectionHandler.SetupCollage()
}
}
// Build recommendations handler if ratings repo is available.
var recsHandler *handlers.RecommendationsHandler
if ratingsRepo != nil {
var recsRepo *recommendations.Repo
var recsReader *recommendations.Reader
if deps.DB != nil {
recsRepo = recommendations.NewRepo(deps.DB)
recsReader = recommendations.NewReader(recsRepo, ratingsRepo, deps.RecWorker, deps.UserStoreProvider)
}
recsHandler = handlers.NewRecommendationsHandler(deps.Recommender, recsReader, deps.UserStoreProvider, ratingsRepo, recsRepo, deps.Recommender != nil)
if deps.DB != nil {
recsFetcher := sections.NewFetcher(deps.DB)
recsFetcher.StoreProvider = deps.UserStoreProvider
recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
recsHandler.Fetcher = recsFetcher
recsHandler.WatchTonightFetcher = recsFetcher
}
if detailSvc != nil {
recsHandler.DetailSvc = detailSvc
}
recsHandler.CalendarRepo = calendarRepo
recsHandler.EpisodeRepo = episodeRepo
if ebookProgressStore != nil {
recsHandler.EbookProgress = ebookProgressStore
}
if deps.PersonRepo != nil {
recsHandler.CastFetcher = deps.PersonRepo
}
if deps.RecWorker != nil {
recsHandler.RecWorker = deps.RecWorker
}
}
// Build download handler.
var downloadHandler *handlers.DownloadHandler
if deps.DB != nil && deps.FileRepo != nil && deps.Config != nil {
downloadRepo := downloads.NewRepository(deps.DB)
downloadBandwidth := downloads.NewBandwidthManager(
deps.Config.Download.ServerBandwidthBPS,
deps.Config.Download.UserBandwidthBPS,
)
downloadLimiter := downloads.NewQuantityLimiter(
downloadRepo,
deps.Config.Download.MaxConcurrentPerUser,
deps.Config.Download.MaxPerPeriod,
deps.Config.Download.PeriodDuration,
)
downloadSvc := downloads.NewService(
downloadRepo,
downloadBandwidth,
downloadLimiter,
deps.FileRepo,
itemRepo,
episodeRepo,
userRepo,
itemRepo,
settingsRepo,
&deps.Config.Download,
)
downloadSvc.SetGroupPolicyProvider(accessGroupStore)
if deps.PolicySystem != nil {
downloadSvc.SetActionDecider(deps.PolicySystem.PDP())
}
if detailSvc != nil {
// Offline manifest + artwork/subtitle proxies (Phase 2). subtitleManager
// may be nil when subtitles are unconfigured; pass a nil interface so the
// downloaded-subtitle path reports unavailable instead of panicking.
var subtitleSource downloads.SubtitleSource
if subtitleManager != nil {
subtitleSource = subtitleManager
}
downloadSvc.SetOfflineDeps(detailSvc, subtitleSource, nil)
}
if deps.ArtifactManager != nil {
// Prepare-to-file pipeline (Phase 3): remux/transcode-to-single-file.
downloadSvc.SetArtifactManager(deps.ArtifactManager)
}
// Series monitoring (auto-download subscriptions). Client-pull only:
// devices sync on app open / background refresh; there is no server
// background worker.
downloadSvc.SetSubscriptions(downloads.NewSubscriptionRepository(deps.DB))
downloadHandler = handlers.NewDownloadHandler(downloadSvc)
if profileHandler != nil {
// Profiles may live outside Postgres (sqlite userdb backend), so
// deleting one cannot FK-cascade the shared user_devices table;
// purge the device library (and its downloads) in-app instead.
profileHandler.DeviceLibraryPurger = downloadRepo
}
} else {
downloadHandler = handlers.NewDownloadHandler(nil)
}
var policyHandler *handlers.PolicyHandler
if deps.PolicySystem != nil && deps.DB != nil {
policyHandler = handlers.NewPolicyHandler(
deps.PolicySystem,
policy.NewPolicyStore(deps.DB),
policy.NewDecisionRepository(deps.DB),
func() bool {
cfg := deps.CurrentConfig()
return cfg != nil && cfg.Policy.EditorEnabled
},
)
}
var historyImportHandler *handlers.HistoryImportHandler
var historyImportSvc *historyimport.Service
if deps.DB != nil {
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
historyImportSvc = historyimport.NewService(deps.AppContext, historyRepo, deps.UserStoreProvider)
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo)
historyImportSvc.SetStableIdentityResolver(historyIdentity)
if deps.EventsHub != nil {
historyImportSvc.AddObserver(evt.NewHistoryImportObserver(deps.EventsHub))
}
historyImportHandler = handlers.NewHistoryImportHandler(historyImportSvc)
if deps.UserStoreProvider != nil {
webhookSyncSvc := webhooksync.NewService(webhooksync.NewRepository(deps.DB, deps.SecretCipher), historyRepo, deps.UserStoreProvider)
webhookSyncSvc.SetStableIdentityResolver(historyIdentity)
webhookSyncHandler = handlers.NewWebhookSyncHandler(webhookSyncSvc)
}
}
// ABS-compat routes are NOT mounted here — they live on a dedicated
// http.Server (see absCompatSrv in cmd/silo/main.go) so the discovery
// probes (/ping, /healthcheck, /status, etc.) don't collide with the
// SPA fallback. Same pattern as the Jellyfin compat listener on 8096.
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", healthHandler.ServeHTTP)
r.Get("/ready", readyHandler.ServeHTTP)
// Branding handler is shared between the public read/serve endpoints
// (registered with the theme endpoints below) and the admin
// upload/delete endpoints (registered in the admin group).
var brandingHandler *handlers.BrandingHandler
if deps.BrandingService != nil {
brandingHandler = handlers.NewBrandingHandler(deps.BrandingService)
}
if webhookSyncHandler != nil {
r.Post("/plex-sync/webhooks/{secret}", webhookSyncHandler.HandleWebhook)
r.Post("/webhook-sync/webhooks/{secret}", webhookSyncHandler.HandleWebhook)
}
// Theme endpoints (admin-css is public for pre-login branding).
if settingsRepo != nil {
themeHandler := handlers.NewThemeHandler(settingsRepo)
r.Get("/theme/admin-css", themeHandler.HandleAdminCSS)
if brandingHandler != nil {
// Public branding read + asset serving (pre-login white-label).
r.Get("/theme/branding", brandingHandler.HandleGetBranding)
r.Get("/branding/assets/{kind}", brandingHandler.HandleServeAsset)
}
// Catalog and download proxies require auth (to avoid open proxy).
if authMiddleware != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
r.Get("/theme/catalog", themeHandler.HandleCatalog)
r.Get("/theme/download", themeHandler.HandleDownload)
r.With(requireActingAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh)
})
}
}
if deps.PluginHTTPProxy != nil {
r.HandleFunc("/plugins/{installation_id}/*", func(w http.ResponseWriter, r *http.Request) {
installationID, err := strconv.Atoi(chi.URLParam(r, "installation_id"))
if err != nil {
http.Error(w, "invalid installation id", http.StatusBadRequest)
return
}
authenticated, admin, userID := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo)
ctx := plugins.WithPluginAccessUser(r.Context(), authenticated, admin, userID)
deps.PluginHTTPProxy.ServeRoute(w, r.WithContext(ctx), installationID, authenticated, admin)
})
r.Get("/plugin-assets/{installation_id}/*", func(w http.ResponseWriter, r *http.Request) {
installationID, err := strconv.Atoi(chi.URLParam(r, "installation_id"))
if err != nil {
http.Error(w, "invalid installation id", http.StatusBadRequest)
return
}
assetPath := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
if assetPath == "" {
http.NotFound(w, r)
return
}
authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo)
deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath)
})
}
// Auth routes: public (no auth required).
if authHandler != nil {
// OAuth handler is optional: it only stands up when PublicURL is
// configured (we need a stable redirect_uri origin for IdPs) and
// the DB is available (oauth_session storage).
var oauthHandler *auth.OAuthHandler
if deps.PublicURL != "" && deps.DB != nil && authService != nil && jwtService != nil {
stateSecret := auth.DeriveOAuthStateSecret([]byte(deps.Config.Auth.JWTSecret))
oauthStore := auth.NewPGOAuthStore(deps.DB, stateSecret)
resolveClient := func(ctx context.Context, installationID int) (auth.OAuthClient, string, error) {
pp := authService.FindOAuthInstallation(installationID)
if pp == nil {
return nil, "", errors.New("plugin not found")
}
c, err := pp.OAuthClient(ctx)
if err != nil {
return nil, "", err
}
return c, pp.CapabilityID(), nil
}
oauthHandler = auth.NewOAuthHandler(auth.OAuthHandlerDeps{
Store: oauthStore,
CompletionStore: oauthStore,
StateSecret: stateSecret,
ResolveClient: resolveClient,
LoginCompleter: authService,
HostBaseURL: deps.PublicURL,
StateTTL: 10 * time.Minute,
})
}
authHandler.SetOAuthRoutesAvailable(oauthHandler != nil)
r.Route("/auth", func(r chi.Router) {
if deps.RateLimitMW != nil {
r.With(deps.RateLimitMW.AuthEndpointHandler("login")).Post("/login", authHandler.HandleLogin)
r.With(deps.RateLimitMW.AuthEndpointHandler("setup")).Post("/setup", authHandler.HandleSetup)
r.With(deps.RateLimitMW.AuthEndpointHandler("signup")).Post("/signup", authHandler.HandleSignup)
} else {
r.Post("/login", authHandler.HandleLogin)
r.Post("/setup", authHandler.HandleSetup)
r.Post("/signup", authHandler.HandleSignup)
}
r.Get("/setup", authHandler.HandleSetupStatus)
r.Get("/providers", authHandler.HandleProviders)
r.Post("/refresh", authHandler.HandleRefresh)
r.Get("/signup", authHandler.HandleSignupStatus)
if authMiddleware != nil {
r.With(authMiddleware.RequireAuth).Post("/plugin-launch", authHandler.HandlePluginLaunch)
}
if oauthHandler != nil {
r.Post("/oauth/complete", oauthHandler.HandleComplete)
r.Route("/oauth/{install_id}", func(r chi.Router) {
r.Post("/init", oauthHandler.HandleInit)
r.Get("/callback", oauthHandler.HandleCallback)
})
}
if deps.RateLimitMW != nil {
r.With(deps.RateLimitMW.AuthEndpointHandler("device_start")).Post("/device/start", authHandler.HandleDeviceStart)
r.With(deps.RateLimitMW.AuthEndpointHandler("device_lookup")).Get("/device", authHandler.HandleDeviceLookup)
r.With(deps.RateLimitMW.AuthEndpointHandler("device_poll")).Post("/device/poll", authHandler.HandleDevicePoll)
} else {
r.Post("/device/start", authHandler.HandleDeviceStart)
r.Get("/device", authHandler.HandleDeviceLookup)
r.Post("/device/poll", authHandler.HandleDevicePoll)
}
// Protected auth routes (require valid session).
if authMiddleware != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
r.Post("/logout", authHandler.HandleLogout)
r.Post("/impersonation/end", authHandler.HandleEndImpersonation)
r.Get("/me", authHandler.HandleMe)
r.Get("/sessions", authHandler.HandleListSessions)
r.Delete("/sessions/{id}", authHandler.HandleDeleteSession)
r.Post("/device/approve", authHandler.HandleDeviceApprove)
r.Post("/device/deny", authHandler.HandleDeviceDeny)
})
}
})
}
// Discord account-link OAuth callback: public — Discord redirects the
// browser here without credentials; the one-time link-state row
// authenticates the request and maps it back to the initiating
// account. The static path coexists with the authenticated
// /notifications subrouter below (static routes win in chi).
var discordNotificationsHandler *handlers.DiscordNotificationsHandler
if deps.Notifications != nil {
discordNotificationsHandler = handlers.NewDiscordNotificationsHandler(deps.Notifications, deps.PublicURL)
r.Get("/notifications/discord/link/callback", discordNotificationsHandler.HandleLinkCallback)
// Tokenized email links: public — clicked from mail clients on
// devices without a Silo session; the single-use token (verify)
// or per-profile capability token (unsubscribe) authenticates the
// request. Static paths coexist with the authenticated
// /notifications subrouter below, same as the Discord callback.
deps.Notifications.SetPublicURL(deps.PublicURL)
emailLinkHandler := handlers.NewEmailLinkHandler(deps.Notifications)
r.Get("/notifications/email/verify", emailLinkHandler.HandleVerify)
r.Get("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe)
r.Post("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe)
}
// API key management routes (auth only, no viewer access needed).
if apiKeyRepo != nil && authMiddleware != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
if demoGuard != nil {
r.Use(demoGuard.Guard)
}
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
r.Route("/api-keys", func(r chi.Router) {
r.Post("/", apiKeyHandler.HandleCreateAPIKey)
r.Get("/", apiKeyHandler.HandleListAPIKeys)
r.Delete("/{id}", apiKeyHandler.HandleDeleteAPIKey)
})
})
}
// All remaining routes require auth.
if authMiddleware != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireAuth)
if demoGuard != nil {
r.Use(demoGuard.Guard)
}
if deps.RateLimitMW != nil {
r.Use(deps.RateLimitMW.Handler)
}
if viewerAccessMiddleware != nil {
r.Use(viewerAccessMiddleware.RequireViewerAccess)
}
// User-facing library route (all authenticated users).
if libraryHandler != nil {
r.Get("/user/libraries", libraryHandler.HandleListUserLibraries)
}
if deps.EventsHub != nil {
eventsHandler := handlers.NewEventsHandler(
deps.EventsHub,
adminJobsHandler,
adminHandler,
deps.TaskManager,
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
)
eventsHandler.SetNotificationsSystem(deps.Notifications)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
// User notifications: profile-scoped inbox, preferences, and
// the websocket handshake ticket.
if deps.Notifications != nil {
if detailSvc != nil {
deps.Notifications.SetImageResolver(detailSvc)
}
notificationsHandler := handlers.NewNotificationsHandler(deps.Notifications, deps.EventsHub)
r.With(apimw.RequireProfile).Post("/events/ws-ticket", notificationsHandler.HandleMintWSTicket)
r.With(apimw.RequireProfile).Post("/devices/push/apple", notificationsHandler.HandleRegisterApplePushDevice)
// Discord DM channel: the linked identity and mode hang off
// the login account, not a profile, so these stay outside
// the RequireProfile subrouter below (static paths coexist
// with it, same as the public email-link routes above).
if discordNotificationsHandler != nil {
r.Get("/notifications/discord-preferences", discordNotificationsHandler.HandleGetPreferences)
r.Put("/notifications/discord-preferences", discordNotificationsHandler.HandleUpdatePreferences)
r.Delete("/notifications/discord-link", discordNotificationsHandler.HandleUnlink)
r.Post("/notifications/discord/link/init", discordNotificationsHandler.HandleLinkInit)
}
r.Route("/notifications", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", notificationsHandler.HandleList)
r.Get("/sync", notificationsHandler.HandleSync)
r.Get("/unread-count", notificationsHandler.HandleUnreadCount)
r.Get("/capability", notificationsHandler.HandleCapability)
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
r.Put("/preferences", notificationsHandler.HandleUpdatePreferences)
r.Get("/push/apple/display/{delivery_id}", notificationsHandler.HandleApplePushDisplay)
r.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences)
r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences)
r.Put("/email-preferences/address", notificationsHandler.HandleRequestEmailAddress)
r.Delete("/email-preferences/address", notificationsHandler.HandleClearEmailAddress)
r.Post("/read-all", notificationsHandler.HandleReadAll)
r.Route("/webhooks", func(r chi.Router) {
r.Get("/", notificationsHandler.HandleListWebhooks)
r.Post("/", notificationsHandler.HandleCreateWebhook)
r.Put("/{id}", notificationsHandler.HandleUpdateWebhook)
r.Delete("/{id}", notificationsHandler.HandleDeleteWebhook)
r.Post("/{id}/rotate-secret", notificationsHandler.HandleRotateWebhookSecret)
r.Post("/{id}/test", notificationsHandler.HandleTestWebhook)
})
r.Route("/web-push", func(r chi.Router) {
r.Get("/subscriptions", notificationsHandler.HandleWebPushList)
r.Post("/subscriptions", notificationsHandler.HandleWebPushSubscribe)
r.Delete("/subscriptions/{id}", notificationsHandler.HandleWebPushDelete)
r.Post("/unsubscribe", notificationsHandler.HandleWebPushUnsubscribe)
})
r.Get("/{id}", notificationsHandler.HandleGet)
r.Post("/{id}/read", notificationsHandler.HandleMarkRead)
})
}
// Marker reads for any authenticated viewer; writes require the
// marker_edit permission, decided by the policy PDP. Users fix
// and create intro/recap/credits/preview markers from the
// player. Writes are stamped source="manual" and contributed to
// enabled providers in the background. Contribution + provider
// config stay admin-only (see the /admin group below).
if markersHandler != nil && markerEditAccess != nil {
r.Route("/markers", func(r chi.Router) {
r.Get("/items/{id}", markersHandler.HandleGetItemMarkers)
r.Get("/files/{fileId}", markersHandler.HandleGetFileMarkers)
r.Group(func(r chi.Router) {
r.Use(markerEditAccess)
r.Put("/items/{id}", markersHandler.HandleSetItemMarkers)
r.Put("/files/{fileId}", markersHandler.HandleSetFileMarkers)
r.Delete("/files/{fileId}/{segment}", markersHandler.HandleClearFileSegment)
})
})
}
// Library management routes (admin-only).
if libraryHandler != nil {
r.Group(func(r chi.Router) {
r.Use(requireActingAdmin)
r.Route("/libraries", func(r chi.Router) {
r.Get("/", libraryHandler.HandleListLibraries)
r.Get("/roots", libraryHandler.HandleListRoots)
r.Put("/roots/override", libraryHandler.HandleUpsertRootOverride)
r.Delete("/roots/override", libraryHandler.HandleDeleteRootOverride)
r.Get("/skipped-roots", libraryHandler.HandleListSkippedRoots)
r.Get("/stale-ids", libraryHandler.HandleListStaleIDs)
r.Post("/stale-ids/{contentID}/rematch", libraryHandler.HandleRematchStaleID)
r.Get("/unmatched-items", libraryHandler.HandleListUnmatchedItems)
r.Get("/metadata-match-queue", libraryHandler.HandleListMetadataMatchQueues)
r.Post("/", libraryHandler.HandleCreateLibrary)
r.Put("/reorder", libraryHandler.HandleReorderLibraries)
r.Put("/{id}", libraryHandler.HandleUpdateLibrary)
r.Delete("/{id}", libraryHandler.HandleDeleteLibrary)
r.Post("/{id}/check-mount", libraryHandler.HandleCheckLibraryMount)
r.Post("/{id}/confirm-empty-root-cleanup", libraryHandler.HandleConfirmEmptyRootCleanup)
r.Get("/{id}/metadata-match-queue", libraryHandler.HandleGetMetadataMatchQueue)
r.Post("/{id}/metadata-match-queue/retry", libraryHandler.HandleRetryMetadataMatchQueue)
r.Post("/{id}/metadata-match-queue/cancel", libraryHandler.HandleCancelMetadataMatchQueue)
r.Post("/{id}/refresh-metadata", libraryHandler.HandleRefreshLibraryMetadata)
r.Get("/{id}/providers", libraryHandler.HandleGetLibraryProviders)
r.Put("/{id}/providers", libraryHandler.HandleSetLibraryProviders)
r.Put("/{id}/poster", libraryHandler.HandleUploadPoster)
r.Delete("/{id}/poster", libraryHandler.HandleDeletePoster)
})
r.Post("/scan", libraryHandler.HandleScan)
r.Post("/scan/cancel", libraryHandler.HandleScanCancel)
})
}
// Browse, search, and item detail routes.
if itemsHandler != nil {
r.Get("/catalog", catalogHandler.HandleGetCatalog)
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups)
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
if literaryWorkHandler != nil {
r.Get("/works/{work_id}", literaryWorkHandler.HandleGetWork)
}
if catalogResourceHandler != nil {
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
r.Get("/catalog/items/{id}/episodes", catalogResourceHandler.HandleGetItemEpisodes)
r.Get("/catalog/items/{id}/versions", catalogResourceHandler.HandleGetItemVersions)
r.Get("/catalog/items/{id}/manga-files", catalogResourceHandler.HandleGetMangaFiles)
r.Get("/catalog/series/{id}/seasons", catalogResourceHandler.HandleGetSeasons)
r.Get("/catalog/series/{id}/seasons/{num}", catalogResourceHandler.HandleGetSeason)
r.Get("/catalog/series/{id}/seasons/{num}/episodes", catalogResourceHandler.HandleGetEpisodes)
}
r.Get("/watch/{id}", itemsHandler.HandleGetWatchDetail)
}
if calendarRepo != nil {
calendarPopular := recommendations.NewRepo(deps.DB)
calendarTrending := sections.NewTrendingSnapshotRepository(deps.DB)
calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc, calendarPopular, calendarTrending)
r.With(apimw.RequireProfile).Get("/calendar", calendarHandler.HandleGetCalendar)
}
if peopleHandler != nil {
r.Get("/people", peopleHandler.HandleSearch)
r.Get("/people/{id}", peopleHandler.HandleGetPerson)
r.Post("/people/{id}/refresh", peopleHandler.HandleRefreshPerson)
}
if libraryCollectionHandler != nil {
r.Get("/library/{id}/collections", libraryCollectionHandler.HandleListLibraryCollections)
r.Get("/library/{id}/collections/{collection_id}/items", libraryCollectionHandler.HandleGetLibraryCollectionItems)
r.Get("/library/{id}/user-collections", libraryCollectionHandler.HandleListLibraryUserCollections)
}
// Profile routes.
if profileHandler != nil {
r.Route("/profiles", func(r chi.Router) {
r.Get("/household/sessions", profileHandler.HandleListHouseholdSessions)
r.Get("/", profileHandler.HandleListProfiles)
r.Post("/", profileHandler.HandleCreateProfile)
r.Put("/{id}", profileHandler.HandleUpdateProfile)
r.Delete("/{id}", profileHandler.HandleDeleteProfile)
r.Put("/{id}/avatar", profileHandler.HandleUploadAvatar)
r.Delete("/{id}/avatar", profileHandler.HandleDeleteAvatar)
r.Post("/{id}/verify-pin", profileHandler.HandleVerifyPIN)
})
}
// Favorites, watchlist, and history routes (profile-scoped).
if personalDataHandler != nil && itemsHandler != nil {
r.Route("/watched", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/{id}", itemsHandler.HandleMarkWatched)
r.Delete("/{id}", itemsHandler.HandleMarkUnwatched)
})
r.Route("/favorites", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", personalDataHandler.HandleListFavorites)
r.Get("/{item_id}", personalDataHandler.HandleCheckFavorite)
r.Put("/{item_id}", personalDataHandler.HandleAddFavorite)
r.Delete("/{item_id}", personalDataHandler.HandleRemoveFavorite)
})
r.Route("/watchlist", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", personalDataHandler.HandleListWatchlist)
r.Get("/{item_id}", personalDataHandler.HandleCheckWatchlist)
r.Put("/{item_id}", personalDataHandler.HandleAddToWatchlist)
r.Delete("/{item_id}", personalDataHandler.HandleRemoveFromWatchlist)
})
r.Route("/history", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", personalDataHandler.HandleListHistory)
r.Post("/remove", personalDataHandler.HandleRemoveHistory)
})
// Ratings routes (profile-scoped).
if ratingsHandler != nil {
r.Route("/ratings", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", ratingsHandler.HandleListRatings)
r.Get("/{item_id}", ratingsHandler.HandleGetRating)
r.Put("/{item_id}", ratingsHandler.HandleSetRating)
r.Delete("/{item_id}", ratingsHandler.HandleDeleteRating)
})
}
}
// Progress and sync routes (profile-scoped).
if progressHandler != nil {
r.Route("/progress", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", progressHandler.HandleListProgress)
})
r.Route("/sync", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/progress", progressHandler.HandleSyncProgress)
})
}
// Collection routes (profile-scoped).
if collectionHandler != nil {
var userImportHandler *handlers.UserCollectionImportHandler
if deps.UserCollectionSync != nil {
userImportHandler = handlers.NewUserCollectionImportHandler(
deps.UserStoreProvider,
deps.UserCollectionSync,
deps.UserCollectionScheduler,
nil,
deps.MDBListClient,
deps.S3Public,
deps.FrontendFS,
4*time.Hour,
)
}
r.Route("/collections", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", collectionHandler.HandleListCollections)
r.Get("/capabilities", collectionHandler.HandleCapabilities)
if libraryCollectionHandler != nil {
// Aggregated server (admin-curated) collections across
// every accessible library. Separate from "/" (personal,
// editable) by design — different access + cache lifecycle.
r.Get("/server", libraryCollectionHandler.HandleListServerCollections)
}
r.Post("/", collectionHandler.HandleCreateCollection)
r.Post("/preview", collectionHandler.HandlePreviewCollection)
r.Put("/order", collectionHandler.HandleReorderCollections)
r.Post("/groups", collectionHandler.HandleCreateCollectionGroup)
r.Put("/groups/order", collectionHandler.HandleReorderCollectionGroups)
r.Put("/groups/{id}", collectionHandler.HandleUpdateCollectionGroup)
r.Delete("/groups/{id}", collectionHandler.HandleDeleteCollectionGroup)
if userImportHandler != nil {
r.Get("/templates", userImportHandler.HandleListTemplates)
r.Get("/import/mdblist/search", userImportHandler.HandleSearchMDBList)
r.Get("/import/mdblist/top", userImportHandler.HandleTopMDBList)
r.Post("/import/mdblist", userImportHandler.HandleImportMDBList)
r.Post("/import/tmdb", userImportHandler.HandleImportTMDB)
r.Post("/import/trakt", userImportHandler.HandleImportTrakt)
r.Post("/{id}/sync", userImportHandler.HandleSync)
}
r.Put("/{id}", collectionHandler.HandleUpdateCollection)
r.Delete("/{id}", collectionHandler.HandleDeleteCollection)
r.Delete("/{id}/image", collectionHandler.HandleDeleteCollectionImage)
r.Get("/{id}/items", collectionHandler.HandleListCollectionItems)
r.Put("/{id}/items/order", collectionHandler.HandleReorderCollectionItems)
r.Put("/{id}/items/{item_id}", collectionHandler.HandleAddCollectionItem)
r.Delete("/{id}/items/{item_id}", collectionHandler.HandleRemoveCollectionItem)
})
}
if homeDismissalHandler != nil {
r.Route("/home/dismissals", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Put("/{surface}/{item_id}", homeDismissalHandler.HandleUpsertDismissal)
r.Delete("/{surface}/{item_id}", homeDismissalHandler.HandleDeleteDismissal)
})
}
if watchProviderHandler != nil {
r.Route("/watch-providers", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", watchProviderHandler.HandleListProviders)
r.Get("/{provider}/connection", watchProviderHandler.HandleGetConnection)
r.Patch("/{provider}/connection", watchProviderHandler.HandleUpdateConnection)
r.Delete("/{provider}/connection", watchProviderHandler.HandleDeleteConnection)
r.Post("/{provider}/auth/device-code", watchProviderHandler.HandleStartDeviceAuth)
r.Post("/{provider}/auth/poll", watchProviderHandler.HandlePollDeviceAuth)
r.Post("/{provider}/auth/api-key", watchProviderHandler.HandleConnectAPIKey)
r.Post("/{provider}/sync", watchProviderHandler.HandleManualSync)
r.Get("/{provider}/sync-runs", watchProviderHandler.HandleListSyncRuns)
})
}
if requestHandler != nil {
r.Route("/requests", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/search", requestHandler.HandleSearch)
r.Get("/discover", requestHandler.HandleDiscover)
r.Get("/discover/studios", requestHandler.HandleListStudios)
r.Get("/discover/networks", requestHandler.HandleListNetworks)
r.Get("/discover/genres", requestHandler.HandleListGenres)
r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio)
r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork)
r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre)
r.Get("/discover/{section}", requestHandler.HandleDiscoverSection)
r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail)
r.Get("/status", requestHandler.HandleGetStatus)
r.Post("/", requestHandler.HandleCreate)
r.Get("/mine", requestHandler.HandleListMine)
r.Get("/{id}", requestHandler.HandleGet)
r.Post("/{id}/cancel", requestHandler.HandleCancel)
})
}
// Settings routes (user-scoped, no profile required).
if settingsHandler != nil {
r.Route("/settings", func(r chi.Router) {
if deps.PluginUserConfig != nil && deps.PluginService != nil {
pluginHandler := handlers.NewPluginHandler(
plugins.NewRepositoryStore(deps.DB),
plugins.NewInstallationStore(deps.DB),
plugins.NewRuntimeConfigStore(deps.DB),
deps.PluginService,
deps.PluginUserConfig,
deps.PluginHTTPProxy,
metadata.NewChainRepository(deps.DB),
deps.PluginImageResolver,
restartStatus,
)
r.Get("/plugins", pluginHandler.HandleListUserPluginSettings)
r.Get("/plugins/{installation_id}", pluginHandler.HandleGetUserPluginSettings)
r.Put("/plugins/{installation_id}", pluginHandler.HandlePutUserPluginSettings)
}
r.Get("/", settingsHandler.HandleListSettings)
r.Get("/overlay-config", settingsHandler.HandleGetOverlayConfig)
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/effective", settingsHandler.HandleGetEffectiveSettings)
r.Get("/subtitle_appearance/effective", settingsHandler.HandleGetEffectiveSubtitleAppearance)
r.Put("/device/subtitle_appearance", settingsHandler.HandleSetSubtitleAppearanceDeviceOverride)
r.Delete("/device/subtitle_appearance", settingsHandler.HandleDeleteSubtitleAppearanceDeviceOverride)
r.Get("/device/{key}", settingsHandler.HandleGetDeviceSetting)
r.Put("/device/{key}", settingsHandler.HandleSetDeviceSetting)
r.Delete("/device/{key}", settingsHandler.HandleDeleteDeviceSetting)
})
r.Get("/{key}", settingsHandler.HandleGetSetting)
r.Put("/{key}", settingsHandler.HandleSetSetting)
r.Delete("/{key}", settingsHandler.HandleDeleteSetting)
})
}
if historyImportHandler != nil {
r.Route("/history-imports", func(r chi.Router) {
r.Get("/sources", historyImportHandler.HandleListSources)
r.Post("/emby-connect/login", historyImportHandler.HandleLoginConnect)
r.Post("/plex/auth/pin", historyImportHandler.HandleCreatePlexPin)
r.Post("/plex/auth/check", historyImportHandler.HandleCheckPlexPin)
r.Get("/runs", historyImportHandler.HandleListRuns)
r.Post("/runs", historyImportHandler.HandleCreateRun)
r.Get("/runs/{id}", historyImportHandler.HandleGetRun)
})
}
if webhookSyncHandler != nil {
r.Route("/plex-sync", func(r chi.Router) {
r.Get("/connections", webhookSyncHandler.HandleLegacyListConnections)
r.Post("/connections", webhookSyncHandler.HandleLegacyCreateConnection)
r.Delete("/connections/{id}", webhookSyncHandler.HandleLegacyDeleteConnection)
r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleLegacyRotateWebhook)
r.Get("/connections/{id}/actors", webhookSyncHandler.HandleLegacyGetActors)
r.Put("/connections/{id}/actors", webhookSyncHandler.HandleLegacyUpdateActors)
})
r.Route("/webhook-sync", func(r chi.Router) {
r.Get("/connections", webhookSyncHandler.HandleListConnections)
r.Post("/connections", webhookSyncHandler.HandleCreateConnection)
r.Put("/connections/{id}", webhookSyncHandler.HandleUpdateConnection)
r.Delete("/connections/{id}", webhookSyncHandler.HandleDeleteConnection)
r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleRotateWebhook)
r.Get("/connections/{id}/events", webhookSyncHandler.HandleListEvents)
r.Get("/connections/{id}/profile-mappings", webhookSyncHandler.HandleGetProfileMappings)
r.Put("/connections/{id}/profile-mappings", webhookSyncHandler.HandleUpdateProfileMappings)
})
}
// Subtitle preference routes (profile-scoped).
if subtitlePrefHandler != nil {
r.Route("/subtitle-prefs", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/{series_id}", subtitlePrefHandler.HandleGetSubtitlePref)
r.Put("/{series_id}", subtitlePrefHandler.HandleSetSubtitlePref)
r.Delete("/{series_id}", subtitlePrefHandler.HandleDeleteSubtitlePref)
})
}
// Audio preference routes (profile-scoped).
if audioPrefHandler != nil {
r.Route("/audio-prefs", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/{series_id}", audioPrefHandler.HandleGetAudioPref)
r.Put("/{series_id}", audioPrefHandler.HandleSetAudioPref)
r.Delete("/{series_id}", audioPrefHandler.HandleDeleteAudioPref)
})
}
// Library playback preference routes (profile-scoped).
if libraryPlaybackPrefHandler != nil {
r.Route("/library-playback-prefs", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", libraryPlaybackPrefHandler.HandleListLibraryPlaybackPrefs)
r.Put("/{library_id}", libraryPlaybackPrefHandler.HandleSetLibraryPlaybackPref)
r.Delete("/{library_id}", libraryPlaybackPrefHandler.HandleDeleteLibraryPlaybackPref)
})
}
if ebookReaderHandler != nil {
r.Route("/ebooks", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/capability", ebookReaderHandler.HandleConversionCapability)
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
r.Get("/{content_id}/annotations", ebookReaderHandler.HandleListAnnotations)
r.Post("/{content_id}/annotations", ebookReaderHandler.HandleCreateAnnotation)
r.Patch("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleUpdateAnnotation)
r.Delete("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleDeleteAnnotation)
})
}
// Metadata AI translation availability probe (the metadata editor
// and detail pages show or hide their translate actions based on
// this) plus the viewer-facing on-view translation trigger.
if metadataAIHandler != nil {
r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus)
if itemRepo != nil {
metadataAIHandler.ItemAccess = itemRepo
metadataAIHandler.SeasonLookup = seasonRepo
metadataAIHandler.EpisodeLookup = episodeRepo
r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView)
}
} else {
r.Get("/metadata/ai/status", handlers.WriteMetadataAIDisabledStatus)
}
// Subtitle search + AI translation routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
fileAuthorizer := &handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
}
subtitleSearchHandler.FileAuthorizer = fileAuthorizer
if subtitleAIHandler != nil {
subtitleAIHandler.FileAuthorizer = fileAuthorizer
}
}
r.Route("/subtitles", func(r chi.Router) {
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
r.Post("/upload", subtitleSearchHandler.HandleUpload)
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
if subtitleAIHandler != nil {
r.Get("/ai/status", subtitleAIHandler.HandleStatus)
r.Get("/ai/quota", subtitleAIHandler.HandleQuota)
r.Post("/ai/translate", subtitleAIHandler.HandleTranslate)
r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs)
r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob)
r.Post("/ai/jobs/{job_id}/cancel", subtitleAIHandler.HandleCancelJob)
} else {
// Answer the capability probe with 200 {"enabled": false}
// when AI translation isn't wired, so the client gets a
// clean negative instead of a 404.
r.Get("/ai/status", handlers.WriteSubtitleAIDisabledStatus)
}
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
})
}
// Playback routes.
if playbackHandler != nil {
playbackHandler.ItemAccess = itemRepo
playbackHandler.EpisodeLookup = episodeRepo
playbackHandler.OriginalLangLookup = itemRepo
playbackHandler.FFmpegLogSink = deps.FFmpegLogSink
r.Route("/playback", func(r chi.Router) {
// HLS transcode delivery — no profile auth needed;
// session ID (UUID) serves as the access token, same
// pattern as /stream/{session_id}.
r.Get("/transcode/{session_id}/master.m3u8", playbackHandler.HandleGetTranscodeManifest)
r.Get("/transcode/{session_id}/segment/{name}", playbackHandler.HandleGetTranscodeSegment)
// Playback realtime control socket — needs auth but not profile.
r.Get("/sessions/{session_id}/control/ws", playbackHandler.HandleSessionWebSocket)
// All mutation routes require profile auth.
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/start", playbackHandler.HandleStartPlayback)
r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress)
r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack)
r.Delete("/{session_id}", playbackHandler.HandleStopPlayback)
r.Post("/transcode/start", playbackHandler.HandleStartTranscode)
})
})
}
if watchTogetherHandler != nil {
r.Route("/watch-together", func(r chi.Router) {
r.Get("/rooms/{room_id}/ws", watchTogetherHandler.HandleRoomWebSocket)
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Post("/rooms", watchTogetherHandler.HandleCreateRoom)
r.Post("/join", watchTogetherHandler.HandleJoinRoom)
r.Get("/rooms/{room_id}", watchTogetherHandler.HandleGetRoom)
r.Put("/rooms/{room_id}/selection", watchTogetherHandler.HandleSelectRoomItem)
r.Patch("/rooms/{room_id}/policy", watchTogetherHandler.HandleUpdateRoomPolicy)
r.Delete("/rooms/{room_id}", watchTogetherHandler.HandleCloseRoom)
r.Get("/rooms/{room_id}/suggestions", watchTogetherHandler.HandleListSuggestions)
r.Post("/rooms/{room_id}/suggestions", watchTogetherHandler.HandleCreateSuggestion)
r.Delete("/rooms/{room_id}/suggestions/{suggestion_id}", watchTogetherHandler.HandleDeleteSuggestion)
r.Post("/rooms/{room_id}/suggestions/{suggestion_id}/vote", watchTogetherHandler.HandleVote)
r.Delete("/rooms/{room_id}/suggestions/{suggestion_id}/vote", watchTogetherHandler.HandleUnvote)
r.Post("/rooms/{room_id}/suggestions/promote", watchTogetherHandler.HandlePromoteSuggestion)
})
})
}
// Stream routes.
if streamHandler != nil {
r.Get("/stream/{session_id}", streamHandler.HandleStream)
r.Head("/stream/{session_id}", streamHandler.HandleStream)
r.Get("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle)
r.Get("/stream/{session_id}/subtitles/{track}/fonts", streamHandler.HandleSubtitleFonts)
}
// Download routes.
if policyHandler != nil {
r.Get("/policy/capability", policyHandler.HandleCapability)
}
r.Route("/downloads", func(r chi.Router) {
r.Get("/capability", downloadHandler.HandleCapability)
r.Post("/", downloadHandler.HandleCreateDownload)
r.Get("/", downloadHandler.HandleListDownloads)
// Series monitoring (auto-download) subscriptions.
r.Post("/subscriptions", downloadHandler.HandleCreateSubscription)
r.Post("/subscriptions/sync", downloadHandler.HandleSyncSubscriptions)
r.Get("/subscriptions", downloadHandler.HandleListSubscriptions)
r.Get("/subscriptions/{id}", downloadHandler.HandleGetSubscription)
r.Patch("/subscriptions/{id}", downloadHandler.HandlePatchSubscription)
r.Delete("/subscriptions/{id}", downloadHandler.HandleDeleteSubscription)
r.Get("/batches/{batch_id}/manifests", downloadHandler.HandleBatchManifests)
r.Patch("/{id}", downloadHandler.HandlePatchDownload)
r.Delete("/{id}", downloadHandler.HandleDeleteDownload)
// GET+HEAD: background download stacks probe with HEAD
// before issuing ranged GETs; http.ServeContent handles
// HEAD natively.
r.Get("/{id}/file", downloadHandler.HandleDownloadFile)
r.Head("/{id}/file", downloadHandler.HandleDownloadFile)
r.Get("/{id}/manifest", downloadHandler.HandleManifest)
r.Get("/{id}/artwork/{kind}", downloadHandler.HandleArtwork)
r.Get("/{id}/subtitles/{ref}", downloadHandler.HandleSubtitle)
})
r.Get("/direct-download", downloadHandler.HandleDirectDownload)
r.Head("/direct-download", downloadHandler.HandleDirectDownload)
// Recipe gallery catalog (no profile required — purely static metadata).
recipeHandler := &handlers.RecipeHandler{}
r.Get("/sections/recipes", recipeHandler.HandleList)
r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates)
// Section endpoints (profile-scoped).
if sectionHandler != nil {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/home/layout", sectionHandler.HandleHomeLayout)
r.Get("/home/sections", sectionHandler.HandleHomeSections)
r.Get("/home/sections/{id}/items", sectionHandler.HandleHomeSectionItems)
r.Get("/library/{id}/layout", sectionHandler.HandleLibraryLayout)
r.Get("/library/{id}/sections", sectionHandler.HandleLibrarySections)
r.Get("/library/{id}/sections/{sectionId}/items", sectionHandler.HandleLibrarySectionItems)
})
r.Route("/profile/sections", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", sectionHandler.HandleGetProfileOverrides)
r.Put("/", sectionHandler.HandleSaveProfileOverrides)
r.Delete("/reset", sectionHandler.HandleResetProfileOverrides)
r.Get("/settings", sectionHandler.HandleSectionSettings)
if sectionSettingsHandler != nil {
r.Get("/flags", sectionSettingsHandler.HandleGetProfileFlag)
}
})
}
// Recommendation routes (profile-scoped).
if recsHandler != nil {
r.Route("/recommendations", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/for-you/main", recsHandler.HandleForYouMain)
r.Get("/for-you/rows", recsHandler.HandleForYouRows)
r.Get("/because-watched/{item_id}", recsHandler.HandleBecauseWatched)
r.Get("/similar/{item_id}", recsHandler.HandleSimilar)
r.Get("/similar-users", recsHandler.HandleSimilarUsers)
r.Get("/taste-profile", recsHandler.HandleTasteProfile)
r.Get("/popular", recsHandler.HandlePopular)
r.Get("/recently-added", recsHandler.HandleRecentlyAdded)
r.Get("/discover", recsHandler.HandleDiscover)
r.Get("/section/{kind}", recsHandler.HandleSection)
r.Get("/section/{kind}/{key}", recsHandler.HandleSection)
r.Get("/watch-tonight", recsHandler.HandleWatchTonight)
r.Get("/watch-tonight/cards", recsHandler.HandleWatchTonightCards)
r.Get("/taste-seed/items", recsHandler.HandleTasteSeedItems)
r.Post("/taste-seed", recsHandler.HandleTasteSeed)
})
}
// Admin routes.
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
metadataItemAccess := requireActingAdmin
if metadataCurationAccess != nil {
metadataItemAccess = metadataCurationAccess
}
r.Group(func(r chi.Router) {
r.Use(metadataItemAccess)
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
if adminMatchHandler != nil {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
if metadataAIHandler != nil {
r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate)
r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs)
r.Post("/items/{id}/metadata-translation/jobs/{job_id}/cancel", metadataAIHandler.HandleCancelJob)
}
})
if adminJobsHandler != nil {
// Curators must poll their own item-refresh jobs, so this stays outside
// the admin-only group. HandleGet enforces per-job authorization.
r.Get("/jobs/{id}", adminJobsHandler.HandleGet)
}
r.Group(func(r chi.Router) {
r.Use(requireActingAdmin)
r.Get("/users", adminHandler.HandleListUsers)
r.Post("/users", adminHandler.HandleCreateUser)
r.Get("/users/{id}", adminHandler.HandleGetUser)
r.Put("/users/{id}", adminHandler.HandleUpdateUser)
r.Delete("/users/{id}", adminHandler.HandleDeleteUser)
r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser)
r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles)
r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings)
r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting)
r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting)
r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting)
r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings)
r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey)
r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting)
r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey)
r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting)
r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings)
r.Get("/devices", adminHandler.HandleListDevices)
r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice)
if accessGroupHandler != nil {
r.Get("/access-groups", accessGroupHandler.HandleList)
r.Post("/access-groups", accessGroupHandler.HandleCreate)
r.Get("/access-groups/{id}", accessGroupHandler.HandleGet)
r.Put("/access-groups/{id}", accessGroupHandler.HandleUpdate)
r.Delete("/access-groups/{id}", accessGroupHandler.HandleDelete)
}
r.Get("/sessions", adminHandler.HandleListSessions)
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
r.Get("/server/status", adminHandler.HandleGetServerStatus)
r.Get("/catalog/search/status", adminHandler.HandleGetCatalogSearchStatus)
if policyHandler != nil {
r.Route("/policy", func(r chi.Router) {
r.Get("/vendor", policyHandler.HandleListVendor)
r.Get("/documents", policyHandler.HandleListDocuments)
r.Post("/documents", policyHandler.HandleCreateDocument)
r.Get("/documents/{id}", policyHandler.HandleGetDocument)
r.Delete("/documents/{id}", policyHandler.HandleDeleteDocument)
r.Get("/documents/{id}/versions", policyHandler.HandleListVersions)
r.Post("/documents/{id}/versions", policyHandler.HandleCreateVersion)
r.Get("/documents/{id}/versions/{version}", policyHandler.HandleGetVersion)
r.Post("/documents/{id}/versions/{version}/activate", policyHandler.HandleActivateVersion)
r.Post("/documents/{id}/enabled", policyHandler.HandleSetDocumentEnabled)
r.Post("/validate", policyHandler.HandleValidate)
r.Post("/simulate", policyHandler.HandleSimulate)
r.Get("/decisions", policyHandler.HandleListDecisions)
r.Get("/decisions/{id}", policyHandler.HandleGetDecision)
})
}
if literaryWorkHandler != nil {
r.Get("/literary-works/items/{content_id}/candidates", literaryWorkHandler.HandleListCandidates)
r.Post("/literary-works/link", literaryWorkHandler.HandleLinkItems)
r.Delete("/literary-works/{work_id}/items/{content_id}", literaryWorkHandler.HandleUnlinkItem)
r.Post("/literary-works/matches/confirm", literaryWorkHandler.HandleConfirmMatch)
r.Post("/literary-works/matches/ignore", literaryWorkHandler.HandleIgnoreMatch)
}
r.Post("/server/restart", serverControlHandler.HandleRestart)
r.Get("/jellyfin-compat/status", adminHandler.HandleGetJellyfinCompatStatus)
r.Patch("/jellyfin-compat/settings", adminHandler.HandleUpdateJellyfinCompatSettings)
r.Post("/jellyfin-compat/web/install", adminHandler.HandleInstallJellyfinCompatWeb)
r.Post("/jellyfin-compat/web/update", adminHandler.HandleUpdateJellyfinCompatWeb)
r.Post("/jellyfin-compat/web/remove", adminHandler.HandleRemoveJellyfinCompatWeb)
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
if sectionSettingsHandler != nil {
r.Get("/settings/sections", sectionSettingsHandler.HandleGet)
r.Put("/settings/sections", sectionSettingsHandler.HandlePut)
}
r.Get("/settings/{key}", adminHandler.HandleGetSetting)
r.Get("/settings", adminHandler.HandleGetSettings)
r.Put("/settings/{key}", adminHandler.HandleUpdateSetting)
if brandingHandler != nil {
// Branding image upload/delete (scalar branding
// fields use the generic settings PUT above).
r.Post("/branding/assets/{kind}", brandingHandler.HandleUploadAsset)
r.Delete("/branding/assets/{kind}", brandingHandler.HandleDeleteAsset)
}
if settingsRepo != nil {
emailHandler := handlers.NewEmailHandler(mail.NewSMTPSender(settingsRepo))
r.Post("/email/test", emailHandler.HandleTest)
}
if discordNotificationsHandler != nil {
r.Post("/notifications/discord/test", discordNotificationsHandler.HandleAdminTest)
}
if deps.Notifications != nil || settingsRepo != nil {
applePushHandler := handlers.NewAdminApplePushHandler(deps.Notifications, settingsRepo)
if deps.Notifications != nil {
r.Post("/notifications/push/apple/test", applePushHandler.HandleTest)
}
if settingsRepo != nil {
r.Post("/notifications/push/relay/register", applePushHandler.HandleRegisterRelay)
}
}
if deps.Notifications != nil && deps.Notifications.ServerChannels != nil {
serverChannelsHandler := handlers.NewAdminServerChannelsHandler(deps.Notifications)
r.Route("/notifications/server-channels", func(r chi.Router) {
r.Get("/", serverChannelsHandler.HandleList)
r.Post("/", serverChannelsHandler.HandleCreate)
r.Put("/{id}", serverChannelsHandler.HandleUpdate)
r.Delete("/{id}", serverChannelsHandler.HandleDelete)
r.Post("/{id}/rotate-secret", serverChannelsHandler.HandleRotateSecret)
r.Post("/{id}/test", serverChannelsHandler.HandleTest)
})
}
if adminIntroHandler != nil {
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
}
if markersHandler != nil {
// Marker read/write/clear live on the authenticated
// /markers routes; writes require marker_edit.
// Contribution and audit history stay admin operations.
r.Post("/files/{fileId}/contribute", markersHandler.HandleContributeFile)
r.Get("/files/{fileId}/contributions", markersHandler.HandleListFileContributions)
r.Get("/markers/history", markersHandler.HandleListMarkerHistory)
r.Get("/markers/files/{fileId}/history", markersHandler.HandleListFileMarkerHistory)
r.Get("/markers/items/{id}/history", markersHandler.HandleListItemMarkerHistory)
}
if adminMarkerProvidersHandler != nil {
r.Get("/markers/providers", adminMarkerProvidersHandler.HandleListProviders)
r.Put("/markers/providers/{provider}", adminMarkerProvidersHandler.HandleUpdateProvider)
r.Post("/markers/providers/{provider}/validate", adminMarkerProvidersHandler.HandleValidateProvider)
}
if peopleHandler != nil {
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
}
if adminImageHandler != nil {
r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages)
r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage)
}
filesystemHandler := handlers.NewFilesystemHandler()
r.Get("/filesystem/browse", filesystemHandler.HandleBrowse)
if catalogSeedHandler != nil {
r.Route("/catalog", func(r chi.Router) {
r.Post("/export", catalogSeedHandler.HandleExport)
r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob)
r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob)
r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob)
r.Get("/import-sources", catalogSeedHandler.HandleListImportSources)
r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources)
r.Post("/import", catalogSeedHandler.HandleImport)
})
}
if adminJobsHandler != nil {
r.Route("/jobs", func(r chi.Router) {
r.Get("/", adminJobsHandler.HandleList)
r.Post("/{id}/cancel", adminJobsHandler.HandleCancel)
})
}
if deps.PluginService != nil && deps.PluginUserConfig != nil {
pluginHandler := handlers.NewPluginHandler(
plugins.NewRepositoryStore(deps.DB),
plugins.NewInstallationStore(deps.DB),
plugins.NewRuntimeConfigStore(deps.DB),
deps.PluginService,
deps.PluginUserConfig,
deps.PluginHTTPProxy,
metadata.NewChainRepository(deps.DB),
deps.PluginImageResolver,
restartStatus,
)
r.Route("/plugins", func(r chi.Router) {
r.Get("/repositories", pluginHandler.HandleListRepositories)
r.Post("/repositories", pluginHandler.HandleCreateRepository)
r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository)
r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository)
r.Get("/catalog", pluginHandler.HandleCatalog)
r.Get("/installations", pluginHandler.HandleListInstallations)
r.Post("/installations", pluginHandler.HandleCreateInstallation)
r.Post("/uploads", pluginHandler.HandleUploadInstallation)
r.Post("/uploads/chunked", pluginHandler.HandleCreateChunkedUpload)
r.Put("/uploads/chunked/{upload_id}/chunks/{chunk_index}", pluginHandler.HandleUploadChunk)
r.Post("/uploads/chunked/{upload_id}/complete", pluginHandler.HandleCompleteChunkedUpload)
r.Delete("/uploads/chunked/{upload_id}", pluginHandler.HandleCancelChunkedUpload)
r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation)
r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate)
r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig)
r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig)
r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding)
r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding)
r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation)
})
}
if historyImportHandler != nil {
r.Route("/history-import-sources", func(r chi.Router) {
r.Get("/", historyImportHandler.HandleAdminListSources)
r.Post("/", historyImportHandler.HandleAdminCreateSource)
r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource)
r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource)
})
r.Route("/history-imports", func(r chi.Router) {
r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin)
r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken)
r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken)
r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers)
r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun)
r.Get("/mappings", historyImportHandler.HandleAdminListMappings)
r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping)
r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping)
r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping)
r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun)
r.Get("/runs", historyImportHandler.HandleAdminListRuns)
r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun)
r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun)
})
}
if sectionHandler != nil {
r.Route("/sections", func(r chi.Router) {
r.Get("/", sectionHandler.HandleListSections)
r.Post("/", sectionHandler.HandleCreateSection)
r.Post("/preview", sectionHandler.HandlePreview)
r.Put("/reorder", sectionHandler.HandleReorderSections)
r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults)
r.Put("/{id}", sectionHandler.HandleUpdateSection)
r.Delete("/{id}", sectionHandler.HandleDeleteSection)
if sectionBulkHandler != nil {
r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate)
}
})
}
if libraryCollectionHandler != nil {
collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil)
r.Route("/collections", func(r chi.Router) {
r.Get("/", libraryCollectionHandler.HandleListAdminCollections)
r.Get("/templates", collectionTemplateHandler.HandleListTemplates)
r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles)
r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle)
r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob)
r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection)
r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection)
r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections)
r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection)
r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection)
r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection)
r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage)
r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems)
r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem)
r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem)
r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList)
r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection)
r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection)
})
}
if libraryCollectionGroupHandler != nil {
r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) {
r.Get("/", libraryCollectionGroupHandler.HandleListGroups)
r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup)
r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups)
})
r.Route("/collection-groups", func(r chi.Router) {
r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup)
r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup)
r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup)
})
}
if deps.NodeRepo != nil {
jwtSecret := ""
if deps.Config != nil {
jwtSecret = deps.Config.Auth.JWTSecret
}
nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret)
r.Route("/nodes", func(r chi.Router) {
r.Get("/", nodeHandler.HandleListNodes)
r.Post("/", nodeHandler.HandleCreateNode)
r.Put("/{id}", nodeHandler.HandleUpdateNode)
r.Delete("/{id}", nodeHandler.HandleDeleteNode)
r.Post("/{id}/check", nodeHandler.HandleCheckNode)
r.Post("/force-reload", nodeHandler.HandleForceReloadNodes)
r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode)
})
// Live node sessions (reads from Redis)
// Note: /admin/sessions is already used for playback sessions from PostgreSQL.
r.Get("/node-sessions", nodeHandler.HandleListSessions)
}
// System inspection.
{
sysJWTSecret := ""
sysFFmpegPath := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
sysFFmpegPath = deps.Config.Playback.FFmpegPath
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
r.Route("/system", func(r chi.Router) {
r.Get("/build", systemHandler.HandleBuildInfo)
r.Get("/hw-accel", systemHandler.HandleHWAccel)
})
}
if deps.RecWorker != nil {
adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker)
r.Route("/recommendations", func(r chi.Router) {
r.Get("/status", adminRecsHandler.HandleStatus)
r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings)
r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles)
r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch)
r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations)
})
}
if inviteCodeRepo != nil {
inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo)
r.Route("/invite-codes", func(r chi.Router) {
r.Get("/", inviteCodeHandler.HandleListInviteCodes)
r.Post("/", inviteCodeHandler.HandleCreateInviteCode)
r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode)
r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode)
r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode)
})
}
if adminSubtitleHandler != nil {
r.Route("/subtitle-providers", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListProviders)
r.Route("/{provider}", func(r chi.Router) {
r.Put("/", adminSubtitleHandler.HandleUpdateProvider)
r.Post("/test", adminSubtitleHandler.HandleTestProvider)
})
})
r.Route("/subtitles", func(r chi.Router) {
r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles)
r.Route("/{id}", func(r chi.Router) {
r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle)
r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle)
r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle)
})
})
}
// Rate limit admin routes. Mounted even when the limiter is not
// running (deps.RateLimitMW == nil) so admins can always reach the
// config; otherwise disabling rate limiting and restarting would
// lock the settings page out of re-enabling it.
if settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus, restartStatus)
r.Route("/rate-limits", func(r chi.Router) {
r.Get("/config", rateLimitHandler.HandleGetConfig)
r.Put("/config", rateLimitHandler.HandleUpdateConfig)
})
}
if apiKeyRepo != nil {
apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo)
r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys)
r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys)
r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey)
r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey)
r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier)
}
if requestHandler != nil {
r.Get("/requests", requestHandler.HandleAdminList)
r.Post("/requests/{id}/approve", requestHandler.HandleApprove)
r.Post("/requests/{id}/decline", requestHandler.HandleDecline)
r.Post("/requests/{id}/cancel", requestHandler.HandleCancel)
r.Post("/requests/{id}/retry", requestHandler.HandleRetry)
r.Get("/request-settings", requestHandler.HandleGetSettings)
r.Put("/request-settings", requestHandler.HandleUpdateSettings)
r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit)
r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit)
r.Get("/request-integrations", requestHandler.HandleListIntegrations)
r.Post("/request-integrations", requestHandler.HandleCreateIntegration)
r.Put("/request-integrations/{id}", requestHandler.HandleUpdateIntegration)
r.Delete("/request-integrations/{id}", requestHandler.HandleDeleteIntegration)
r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions)
}
if autoscanHandler != nil {
r.Get("/autoscan/settings", autoscanHandler.HandleGetSettings)
r.Put("/autoscan/settings", autoscanHandler.HandleUpdateSettings)
r.Get("/autoscan/connections", autoscanHandler.HandleListConnections)
r.Post("/autoscan/connections", autoscanHandler.HandleCreateConnection)
r.Put("/autoscan/connections/{id}", autoscanHandler.HandleUpdateConnection)
r.Delete("/autoscan/connections/{id}", autoscanHandler.HandleDeleteConnection)
r.Post("/autoscan/connections/test", autoscanHandler.HandleTestConnection)
r.Get("/autoscan/scan-source-plugins", autoscanHandler.HandleListAvailableScanSources)
r.Get("/autoscan/sources", autoscanHandler.HandleListSources)
r.Post("/autoscan/sources", autoscanHandler.HandleCreateSource)
r.Put("/autoscan/sources/{id}", autoscanHandler.HandleUpdateSource)
r.Delete("/autoscan/sources/{id}", autoscanHandler.HandleDeleteSource)
r.Get("/autoscan/sources/{id}/rewrite-suggestions", autoscanHandler.HandleRewriteSuggestions)
r.Get("/autoscan/scans", autoscanHandler.HandleListScans)
r.Get("/autoscan/events", autoscanHandler.HandleListEvents)
r.Post("/autoscan/trigger", autoscanHandler.HandleTrigger)
r.Get("/autoscan/status", autoscanHandler.HandleStatus)
}
if deps.ActivityLogRepo != nil {
adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo)
r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs)
r.Get("/ips", adminIPHandler.HandleGetIPUsers)
}
if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil {
adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub)
r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs)
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession)
r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession)
r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession)
}
if deps.TaskManager != nil {
taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB)
taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB))
taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics)
r.Route("/tasks", func(r chi.Router) {
r.Get("/", taskHandler.HandleListTasks)
r.Get("/{key}", taskHandler.HandleGetTask)
r.Get("/{key}/metrics", taskHandler.HandleGetMetrics)
r.Post("/{key}/run", taskHandler.HandleRunTask)
r.Post("/{key}/cancel", taskHandler.HandleCancelTask)
r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers)
r.Get("/{key}/history", taskHandler.HandleGetHistory)
})
}
})
})
}
})
}
})
return r
}
// pgSubtitleMediaResolver implements handlers.SubtitleMediaResolver using a direct PG query.
type pgSubtitleMediaResolver struct {
pool *pgxpool.Pool
}
func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context, fileID int) (*handlers.MediaFileMetadata, error) {
var meta handlers.MediaFileMetadata
err := r.pool.QueryRow(ctx, `
SELECT
mf.id,
mf.file_path,
COALESCE(mf.file_size, 0),
COALESCE(mf.file_hash, ''),
COALESCE(mf.resolution, ''),
COALESCE(mf.codec_video, ''),
COALESCE(mf.codec_audio, ''),
mi.title,
COALESCE(mi.year, 0),
COALESCE(mi.imdb_id, ''),
COALESCE(e.season_number, 0),
COALESCE(e.episode_number, 0)
FROM media_files mf
JOIN media_items mi ON mi.content_id = mf.content_id
LEFT JOIN episodes e ON e.content_id = mf.episode_id
WHERE mf.id = $1
`, fileID).Scan(
&meta.FileID,
&meta.FilePath,
&meta.FileSize,
&meta.FileHash,
&meta.Resolution,
&meta.VideoCodec,
&meta.AudioCodec,
&meta.Title,
&meta.Year,
&meta.IMDbID,
&meta.Season,
&meta.Episode,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &meta, nil
}
func resolveOptionalPluginAccess(
r *http.Request,
jwtService *auth.JWTService,
sessionRepo *auth.SessionRepository,
) (bool, bool) {
authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, nil, nil)
return authenticated, admin
}
// resolveOptionalPluginAccessUser is like resolveOptionalPluginAccess but also
// returns the authenticated user's ID, and accepts API-key bearer tokens
// (sa_*) when apiKeyRepo + userRepo are provided.
func resolveOptionalPluginAccessUser(
r *http.Request,
jwtService *auth.JWTService,
sessionRepo *auth.SessionRepository,
apiKeyRepo *auth.APIKeyRepository,
userRepo *auth.UserRepository,
) (bool, bool, int) {
if jwtService == nil || sessionRepo == nil {
return false, false, 0
}
token := ""
if header := r.Header.Get("Authorization"); header != "" {
parts := strings.SplitN(header, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
token = strings.TrimSpace(parts[1])
}
}
if token == "" {
token = strings.TrimSpace(r.URL.Query().Get("token"))
}
if token == "" {
if cookie, err := r.Cookie(auth.PluginAccessCookieName); err == nil {
token = strings.TrimSpace(cookie.Value)
}
}
if token == "" {
return false, false, 0
}
if strings.HasPrefix(token, "sa_") {
if apiKeyRepo == nil || userRepo == nil {
return false, false, 0
}
apiKey, err := apiKeyRepo.GetByKey(r.Context(), token)
if err != nil {
return false, false, 0
}
user, err := userRepo.GetByID(r.Context(), apiKey.UserID)
if err != nil || !user.Enabled {
return false, false, 0
}
return true, user.Role == "admin", user.ID
}
claims, err := jwtService.ValidateToken(token)
if err != nil || (claims.TokenType != auth.TokenTypeAccess && claims.TokenType != auth.TokenTypePluginAccess) {
return false, false, 0
}
valid, err := sessionRepo.IsValid(r.Context(), claims.SessionID)
if err != nil || !valid {
return false, false, 0
}
return true, claims.Role == "admin", claims.UserID
}
// NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key.
// Exported so main.go can construct it for the collection sync scheduler.
func NewTMDBCollectionFetcher(apiKey string) catalog.TMDBCollectionFetcher {
return &tmdbCollectionAdapter{
client: tmdb.NewClient(apiKey, 40),
}
}
// tmdbCollectionAdapter adapts the tmdb.Client to the catalog.TMDBCollectionFetcher interface.
type tmdbCollectionAdapter struct {
client *tmdb.Client
}
func (a *tmdbCollectionAdapter) GetCollectionPreset(ctx context.Context, preset, mediaType, timeWindow string, limit int) ([]catalog.TMDBCollectionEntry, error) {
results, err := a.client.GetCollectionPreset(ctx, preset, mediaType, timeWindow, limit)
if err != nil {
return nil, err
}
entries := make([]catalog.TMDBCollectionEntry, len(results))
for i, r := range results {
entry := catalog.TMDBCollectionEntry{
ID: r.ID,
MediaType: r.MediaType,
Title: r.Title,
}
// Fetch external IDs (IMDb, TVDB) for better matching against local library.
if externalIDs, err := a.client.GetExternalIDs(ctx, r.MediaType, r.ID); err == nil && externalIDs != nil {
entry.IMDbID = externalIDs.IMDbID
entry.TVDBID = externalIDs.TVDBID
}
entries[i] = entry
}
return entries, nil
}
// tmdbFranchiseAdapter adapts tmdb.Client to catalog.TMDBCollectionByIDFetcher
// for the `tmdb_collection` sync mode. Like the preset adapter, it enriches
// each TMDB collection part with external IDs so the catalog matcher can fall
// back to IMDb/TVDB when a local item lacks a TMDB ID.
type tmdbFranchiseAdapter struct {
client *tmdb.Client
}
func (a *tmdbFranchiseAdapter) GetCollection(ctx context.Context, id int) ([]catalog.TMDBCollectionEntry, error) {
collection, err := a.client.GetCollection(ctx, id)
if err != nil {
return nil, err
}
if collection == nil {
return nil, nil
}
entries := make([]catalog.TMDBCollectionEntry, len(collection.Parts))
for i, p := range collection.Parts {
mediaType := p.MediaType
if mediaType == "" {
mediaType = "movie"
}
entry := catalog.TMDBCollectionEntry{
ID: p.ID,
MediaType: mediaType,
Title: p.Title,
}
if externalIDs, err := a.client.GetExternalIDs(ctx, mediaType, p.ID); err == nil && externalIDs != nil {
entry.IMDbID = externalIDs.IMDbID
entry.TVDBID = externalIDs.TVDBID
}
entries[i] = entry
}
return entries, nil
}
// tmdbDiscoverAdapter adapts tmdb.Client to catalog.TMDBDiscoverFetcher for
// the `tmdb_discover` sync mode. Like the preset adapter, it enriches each
// result with external IDs so the catalog matcher can fall back to IMDb/TVDB
// when a local item lacks a TMDB ID.
type tmdbDiscoverAdapter struct {
client *tmdb.Client
}
func (a *tmdbDiscoverAdapter) Discover(ctx context.Context, mediaType string, params catalog.TMDBDiscoverParams, limit int) ([]catalog.TMDBCollectionEntry, error) {
results, err := a.client.Discover(ctx, mediaType, tmdb.DiscoverParams{
WithGenres: params.WithGenres,
WithoutGenres: params.WithoutGenres,
SortBy: params.SortBy,
VoteCountGte: params.VoteCountGte,
VoteAverageGte: params.VoteAverageGte,
ReleaseDateGte: params.ReleaseDateGte,
ReleaseDateLte: params.ReleaseDateLte,
Certifications: params.Certifications,
CertificationLte: params.CertificationLte,
WithRuntimeGte: params.WithRuntimeGte,
WithRuntimeLte: params.WithRuntimeLte,
OriginalLanguage: params.OriginalLanguage,
Limit: limit,
})
if err != nil {
return nil, err
}
entries := make([]catalog.TMDBCollectionEntry, len(results))
for i, r := range results {
entry := catalog.TMDBCollectionEntry{
ID: r.ID,
MediaType: r.MediaType,
Title: r.Title,
}
if externalIDs, err := a.client.GetExternalIDs(ctx, r.MediaType, r.ID); err == nil && externalIDs != nil {
entry.IMDbID = externalIDs.IMDbID
entry.TVDBID = externalIDs.TVDBID
}
entries[i] = entry
}
return entries, nil
}
type traktCollectionAdapter struct {
client *metatrakt.Client
}
func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset, mediaType string, limit int, accessToken string) ([]catalog.TraktCollectionEntry, error) {
results, err := a.client.GetCollectionPreset(ctx, preset, mediaType, limit, accessToken)
if err != nil {
return nil, err
}
entries := make([]catalog.TraktCollectionEntry, len(results))
for i, r := range results {
entries[i] = catalog.TraktCollectionEntry{
TraktID: r.TraktID,
TMDBID: r.TMDBID,
TVDBID: r.TVDBID,
IMDbID: r.IMDbID,
MediaType: r.MediaType,
Title: r.Title,
Year: r.Year,
Rank: r.Rank,
}
}
return entries, nil
}
func (a *traktCollectionAdapter) GetUserList(ctx context.Context, user, list string, limit int, accessToken string) ([]catalog.TraktCollectionEntry, error) {
results, err := a.client.GetUserList(ctx, user, list, limit, accessToken)
if err != nil {
return nil, err
}
entries := make([]catalog.TraktCollectionEntry, len(results))
for i, r := range results {
entries[i] = catalog.TraktCollectionEntry{
TraktID: r.TraktID,
TMDBID: r.TMDBID,
TVDBID: r.TVDBID,
IMDbID: r.IMDbID,
MediaType: r.MediaType,
Title: r.Title,
Year: r.Year,
Rank: r.Rank,
}
}
return entries, nil
}
// llmConfigFromServer derives the shared AI client config from the server
// config. Used at construction and again on every config reload.
func llmConfigFromServer(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: cfg.AI.BaseURL,
APIKey: cfg.AI.APIKey,
ChatModel: cfg.AI.ChatModel,
ASRBaseURL: cfg.AI.ASRBaseURL,
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: cfg.AI.ASRModel,
}
}
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
// timestamped transcriptions, so transcription is disabled rather than
// letting every job fail; the settings API rejects such values for the ASR
// URL, but the chat base URL legitimately may be one — this catches the
// blank-ASR-URL fallback case. The second return is the offending endpoint
// when that guard fired, empty otherwise.
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
effectiveASRBase := cfg.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = cfg.AI.BaseURL
}
disabledGateway := ""
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
transcribeEnabled = false
disabledGateway = effectiveASRBase
}
return subtitleai.Config{
Configured: cfg.AI.BaseURL != "",
TranslateEnabled: cfg.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: cfg.AI.ChatModel,
ASRModel: cfg.AI.ASRModel,
BatchSize: cfg.SubtitleAI.BatchSize,
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
LiveASRChunkSeconds: cfg.SubtitleAI.LiveASRChunkSeconds,
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
}, disabledGateway
}
func warnChatOnlyGateway(endpoint string) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
}
type scopeEntitlementResolver struct {
resolver apimw.ViewerResolver
}
func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) {
scope, err := r.resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
if err != nil {
return "", err
}
return scope.MaxPlaybackQuality, nil
}
// metadataAIConfigFromServer derives the metadata translation service config
// from the server config. Used at construction and on every config reload.
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
return metadatatranslation.Config{
Enabled: cfg.MetadataAI.Enabled,
Configured: cfg.AI.BaseURL != "",
ChatModel: cfg.AI.ChatModel,
OnView: cfg.MetadataAI.OnView,
}
}