* 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>
3184 lines
138 KiB
Go
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,
|
|
}
|
|
}
|