* fix(web): scope realtime user state events * feat(events): add canonical catalog event publishers * feat(events): publish canonical catalog events * refactor(web): centralize realtime events provider * feat(events): normalize user state event name * feat(web): patch item user state from realtime events * fix(web): refetch active catalog on realtime changes * fix(events): publish item changes during metadata enrichment * fix(web): improve dashboard and mutation reactivity * feat(admin): improve realtime session activity * feat(admin): refine playback admin surfaces * feat(admin): improve library task controls * fix(collections): position defaults progress below header * feat(library): surface matcher backlog * fix(admin): hide matcher backlog from server activity * chore(migrations): renumber branch migrations * feat(admin): show registered devices without overrides * feat(admin): improve scheduled task visibility * fix(realtime): tighten admin update handling * docs(admin): document library job id parsing * docs(library): explain mount check feedback timing * fix(library): guard metadata match queue handlers * fix(admin): avoid stale queued job cancellation * fix(settings): harden device registration and task timing * fix(jellycompat): fill large browse pages * perf(jellycompat): compress and batch list image work * feat(autoscan): pluggable scan-source autoscan category (Sonarr/Radarr) (#44) * docs: design spec for autoscan arr polling Periodic poller over autoscan-enabled Radarr/Sonarr instances (reusing request_integrations) that maps import paths to Silo media folders and enqueues targeted scans via the existing scantrigger + scanqueue. Lean single-service model: no cross-node fan-out guard or retry queue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for autoscan arr polling Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): settings and sources schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): core types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): path rewrite helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): dedupe imported paths to parent folders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): arr import-history client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): settings + sources repository Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): redis scan-suppression seam Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): PollOnce poll cycle * feat(autoscan): poll task and wiring * feat(autoscan): admin API endpoints * feat(autoscan): admin API endpoints Adds ErrIntegrationNotFound sentinel (errors.Is) instead of string matching. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan types and hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): autoscan admin tab * fix(autoscan): release suppression claim on enqueue failure; reconfigure trigger on interval change; skip source on key-resolution error Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): update handler test for 3-arg NewAutoscanHandler * fix(autoscan): per-path suppression key, bounded poll window + overlap, boundary-safe rewrites, GREATEST cursor guard, async trigger, quiet unresolved-path skip, FK->404 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): normalize Windows path separators, surface status errors, re-seed source editor on save Addresses minor code-review findings: Windows backslash paths now normalized before rewrite/dedupe; HandleStatus returns repository errors instead of 200; the per-source editor re-seeds from server data after a save. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: design spec for autoscan rewrite-sync from arr root folders Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for autoscan rewrite-sync Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): suffix-match rewrite suggester Add suggestRewrites / commonSuffixLen for Task 1 of the autoscan arr-polling feature. Pure function: matches arr root-folder paths to Silo media folder paths by longest common trailing segment count, adjusted for depth-delta so coincidental same-named segments at different structural levels don't inflate confidence. Categorises each arr root as Proposed, Ambiguous, Unmatched, or Covered by an existing PathRewrite rule. TDD: test file written first, verified failing, then implementation added. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): GetSource single-source lookup * feat(autoscan): arr root-folder client + Silo folder lister * feat(autoscan): Service.SuggestRewrites Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): rewrite-suggestions endpoint Add GET /autoscan/sources/{id}/rewrite-suggestions admin endpoint: extend the autoscanTriggerer interface with SuggestRewrites, wire SetRewriteResolvers in the router, and add handler + test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): autoscan rewrite-suggestions types and hook * feat(web): autoscan sync-rewrites preview * fix(autoscan): normalize covered-rule paths, dedup roots/folders, skip no-op suggestions Addresses final-review edge cases: coveredBy normalizes the existing rewrite's From (so a stored Windows/dup-slash rule still covers a root); duplicate arr roots and duplicate Silo folder paths are de-duplicated; an arr path that already equals its Silo path is not proposed as a no-op rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): vitest 4 compatible fetch spy in recipes.test (unblocks build after vitest 4.1.0 bump) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): non-null suggestion slices + move Sync into rewrites card - suggestRewrites initializes Proposed/Unmatched/Ambiguous/Covered to empty slices so the JSON response is [] not null — fixes the 'Something went wrong' crash when every root is already covered (frontend mapped over null). - Move the sync button into the Path rewrites card beside 'Add rewrite' and rename it 'Sync rewrites'; guard the proposed map with ?? []. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): long root-folder timeout + sync spinner + collapse rewrites on load - Root-folder fetch for sync uses a 2-min timeout: Radarr/Sonarr compute unmappedFolders by scanning all roots, so a large library's /rootfolder takes 20-30s+ and tripped arrclient's 30s default (Sonarr 502'd at exactly 30s). - Spin the sync icon + show 'Syncing…' while the request is in flight. - Path rewrites card starts collapsed on page load. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): rescan on Sonarr/Radarr file renames History polling previously only tracked downloadFolderImported events. A rename in Sonarr/Radarr (episodeFileRenamed / movieFileRenamed) moves a file without an import event, leaving the library folder stale until the next full scan. Extend the history client to also surface renamed paths: both the new path and the old sourcePath, since a rename can move a file between folders and both parents may need rescanning. Delete events are still skipped — upgrade-deletes are covered by the paired import, and standalone deletes carry no file path in arr history. Renames the interface method ImportedPaths -> ChangedPaths to reflect the broader scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(autoscan): synchronize trigger test with detached PollOnce goroutine HandleTrigger dispatches PollOnce on a detached goroutine and responds 202 immediately. The test read trig.called straight after the handler returned, racing the goroutine (usually 'PollOnce was not invoked') and reading the field without synchronization (a data race under -race). Signal completion through a channel the fake sends on when PollOnce runs; the test waits on it (bounded) before asserting. The channel send happens-before the receive, so the subsequent read of called is race-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: design spec for autoscan as a pluggable scan-source category Reframes autoscan from a Requests-coupled, arr-only feature into a standalone Autoscan category. Change-detection providers become out-of-process plugins via a new additive scan_source.v1 capability (client-pull, opaque marker); Sonarr/Radarr is the first provider. Host keeps a provider-agnostic resolve/suppress/enqueue engine; all arr-specific logic (and path rewrites) move into the plugin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for scan_source.v1 SDK capability First of the per-repo plans from the autoscan-plugin-architecture spec. Adds the additive scan_source.v1 capability to silo-plugin-sdk (proto + codegen + capability allowlist + runtime wiring), TDD per task, tagged as v0.5.0 so the host and arr-plugin plans can build against it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for autoscan host backend (part 1 of 2) Backend for the standalone Autoscan category: scan_source.v1 plugin plumbing (pluginhost client + plugins.Service resolver), generalized engine driven by a provider seam, autoscan_connections + autoscan_sources schema (decoupled from Requests), connection resolution (own or Requests-linked), admin API. Depends on silo-plugin-sdk v0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plans for autoscan arr plugin + host UI arr plugin: new installable scan_source.v1 plugin (history imports+renames, rewrites, Silo-native paths), structured like silo-plugin-tmdb; ports the arr-specific logic from the closed PR #43. host UI (part 2 of 2): standalone Autoscan admin category (connections, sources, settings) extracted out of Requests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(autoscan): replace silo-plugin-sdk with local scan_source.v1 checkout Temporary dev replace so the host backend can build against the unreleased scan_source.v1 capability (silo-plugin-sdk PR #2). Finalize to v0.5.0 once the SDK is tagged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(pluginhost): scan_source.v1 capability client wrapper Adds ScanSourceClient struct, the Client.ScanSource() accessor (mirrors ScheduledTask pattern), and a PollChanges method. Also introduces client_test.go with capability-gate tests for both scheduled_task.v1 and scan_source.v1 using a lazy gRPC ClientConn. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test+fix(pluginhost): cover capability-id gate, dedicated scan_source timeout Adds a "wrong id returns error" subtest to both capability-gate tests so the capability-ID component is exercised independently of the type. Introduces DefaultScanSourceTimeout (2m) for PollChanges, which polls an external arr API that can be slow, instead of the generic 10s control timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): expose scan_source.v1 client resolver Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(migrations): autoscan v2 schema (connections + sources, no requests FK) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): v2 types and repository Replace the request_integrations-coupled model with the decoupled v2 schema (autoscan_settings + autoscan_connections + autoscan_sources). Connection CRUD, source upsert/list/get, and AdvanceMarker/RecordError for opaque marker bookkeeping. ErrIntegrationNotFound becomes ErrNotFound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): resolve connections (own credentials or Requests-linked) ConnectionResolver turns a stored Connection into concrete credentials, reading a soft-linked Requests integration's live base URL/key when RequestIntegrationID is set, then resolving the api-key ref to plaintext. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): scan-source provider seam over the plugin resolver ScanSourceProvider lets the engine poll changed paths without a live plugin; pluginProvider adapts plugins.Service.ScanSourceClient in production. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): generic engine drives sources via scan_source provider Rewrite PollOnce to iterate enabled sources, resolve each connection, poll the provider for changed paths, and run the salvaged resolve→suppress→enqueue loop (uniqueParentDirs, (folder,path) suppression key, RequestError quiet-skip, release-claims-on-enqueue-fail) verbatim. Store the opaque next marker via AdvanceMarker only after a successful enqueue; RecordError + keep marker on provider failure. Tests reworked onto a fakeProvider/fakeStore with an added opaque-marker-verbatim assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): drop conflicting connection CHECK, add connection resolver tests - migration 172: remove the autoscan_connections_source_present CHECK. It conflicted with request_integration_id ON DELETE SET NULL: deleting a Requests integration that a linked-only connection (base_url NULL) points at would null the FK and trip the CHECK, blocking the delete. The intended behavior is for the connection to survive as an orphaned 'needs attention' row. Creation-time validity is now enforced at the application layer. Verified on a throwaway DB: full chain applies and the delete-cascade leaves an orphaned (both-null) connection. - connection.go: TrimSpace the api key ref + resolved secret before the empty-string checks, matching requests.resolveAPIKey parity. - connection_test.go: fake-based tests for ConnectionResolver.Resolve (own creds, linked, linked-missing error, lookup error, trim/fallback). - repository.go: bound RecordError's stored last_error to 2048 chars. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(api): autoscan v2 admin endpoints Rewrite the autoscan admin HTTP handler against the v2 model: settings, connection CRUD, source update, manual trigger (detached PollOnce), and status. Connection/source responses omit api_key_ref and resolved keys (has_api_key flag only); unknown connection/source ids map to 404 via autoscan.ErrNotFound. Retire the host-side rewrite-suggestions endpoint (now lives in the arr plugin). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): wire v2 service, routes, retire rewrite-suggestions Export PollChangesClient/ScanSourceResolver from the autoscan provider so the api package can declare a structurally-conformant plugin adapter (Go has no return-type covariance, so the adapter must name the interface as its return type). Add api.BuildAutoscanService with the requests-integration lookup and plugin scan-source adapters, shared by the router (manual trigger) and the background poll task. Re-wire router routes to the v2 connections/sources/settings/trigger/status surface and drop the rewrite-suggestions route. Update cmd/silo to build the v2 poll task, seeding its interval from Settings.DefaultPollIntervalSeconds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(api): enforce connection requires own URL or a Requests link Migration 172 dropped the DB CHECK that required an autoscan connection to carry either its own base_url or a request_integration_id, delegating that invariant to the application layer — but the enforcement was never added, so HandleCreateConnection/HandleUpdateConnection accepted both-NULL orphans that ConnectionResolver.Resolve would hand a plugin as an empty base URL. Add a shared validateConnectionInput helper (whitespace-only request_integration_id counts as absent) and reject both-empty payloads with HTTP 400 on both the create and update paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): deliver resolved connection to plugin PollChanges now populates PollChangesRequest.Connection with the resolved {base_url, api_key} instead of dropping the conn param on the floor. Drops the stale doc comment claiming the connection was delivered out-of-band at upsert time -- that mechanism never existed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): auto-discover sources from installed scan_source plugins Auto-discovery seeds a disabled, connection-less source row per installed scan_source.v1 capability before an operator binds a connection, so connection_id is now nullable end to end: - migration 172: connection_id drops NOT NULL (still ON DELETE RESTRICT) - Source.ConnectionID becomes *string; repository scans/writes it as nullable and adds idempotent EnsureSource (INSERT ... ON CONFLICT DO NOTHING) - new ScanSourceLister seam + Service.DiscoverSources, called at the start of PollOnce (errors logged, non-fatal); production adapter enumerates ListEnabled -> ListCapabilities filtered to scan_source.v1 - PollOnce skips an enabled source with no connection bound, recording 'no connection bound' so the UI can surface it - HandleUpdateSource rejects enabling a source with no effective connection (400); source DTOs expose connection_id as nullable - BuildAutoscanService / NewService thread the installation store at both wiring sites (router + poll task) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): honor per-source poll interval PollOnce now skips an enabled source that ran too recently: the floor is source.PollIntervalSeconds when set, else settings.DefaultPollIntervalSeconds. The global poll task fires at the default cadence, so this makes the per-source interval a 'poll at most every N seconds' floor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(autoscan): reconcile spec + arr-plugin plan with credential-in-request + auto-discovery The credential-delivery mechanism changed during execution: the host now passes resolved {base_url, api_key} in PollChangesRequest.connection each poll (not plugin runtime config). Also records source auto-discovery, nullable connection_id, and the per-source interval floor decided at the final integration review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan v2 types and query hooks Replace v1 autoscan types and hooks with v2 DTOs matching the backend handler (autoscan.go): settings, connection (with has_api_key, no raw key), source (installation_id/capability_id/connection_id), status. Add connections CRUD hooks, useAutoscanStatus, update sources hook to v2 input shape. Retain deprecated shims for AutoscanPathRewrite, AutoscanRewriteSuggestions, and useAutoscanRewriteSuggestions so AdminRequests.tsx continues to compile until Task 6 removes that tab. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan connections panel (reuse or own) Card+Table listing connections with "Reused from Requests" / "Own" badges. Add/edit dialog with two modes: reuse a Sonarr/Radarr Requests integration or enter own name/URL/API-key credentials. Delete with alert-dialog confirm. Never renders key material — only has_api_key is sent by the backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan sources panel Table of auto-discovered scan sources (one row per installed scan_source plugin capability). Operator can bind a connection via inline Select (auto-saved on change), set a per-source poll interval (saved on blur), and toggle enabled. Shows a "Needs connection" badge for unbound sources; attempting to enable without a connection lets the backend 400 surface via the existing toast in useUpdateAutoscanSource.onError. Status column shows last_run_at relative time or last_error with icon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): standalone Autoscan admin page Tabs page (Sources | Connections | Settings) mirroring AdminRequests header/layout. Settings tab exposes global enable switch, default poll interval, and debounce — all auto-saved on blur or toggle. "Run now" button calls useTriggerAutoscan and toasts "Autoscan triggered" on 202. Route and sidebar nav are intentionally deferred to Task 5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): route and sidebar nav for Autoscan category Add /admin/autoscan route pointing to AdminAutoscan and a matching "Autoscan" item in the Content group of the admin sidebar (with RefreshCw icon), so the new standalone page is reachable from the nav. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): move Autoscan out of Requests into its own category Remove the Autoscan tab, AutoscanTab/AutoscanSourceEditor component definitions, and AutoscanSettingsFormState from AdminRequests.tsx. Delete the Task-1 compatibility stubs: AutoscanPathRewrite and AutoscanRewriteSuggestions types from api/types.ts, and the useAutoscanRewriteSuggestions no-op shim from useAutoscan.ts. The build confirms zero dangling references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): allow unbinding a source connection (full-state source update) Change the source-update input struct's connection_id from string to *string so the UI can send null to unbind, a UUID to bind, or omit (null) to clear. Remove the fall-back-to-existing logic; the handler now sets the source's ConnectionID directly from the input. The enable-guard fires when the resulting connection is nil regardless of cause. Frontend sends the complete triple (connection_id, enabled, poll_interval_seconds) on every mutation site; selecting "— No connection —" sends null for a real unbind. Adds aria-label to connection Select and interval Input for accessibility. Backend tests cover bind, unbind, unbind while enabled → 400, and enable without connection → 400. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill autoscan v1 settings+connections instead of dropping Migration 172 unconditionally DROPped the shipped v1 autoscan_settings/ autoscan_sources (migration 171), losing an upgraded operator's enable flag, poll cadence, debounce, and arr server list — autoscan came back OFF. Rewrite 172 up to be non-destructive of what can be carried: rename the v1 tables aside, create the v2 schema, backfill settings (poll minutes -> seconds) and seed a reusable LINKED connection per distinct v1 source integration, then drop the renamed v1 tables. v2 sources are keyed on a plugin (installation_id, capability_id) that did not exist in v1, so they are left to runtime discovery; path rewrites move to plugin config and are intentionally not carried. Verified against a throwaway DB: after 171 + v1 seed data, applying 172 yields enabled=true, default_poll_interval_seconds=300, debounce_seconds=30, and one autoscan_connections row linked to the v1 integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): preserve api key on metadata-only connection edit UpdateConnection unconditionally wrote api_key_ref = nullable(c.APIKeyRef), so a metadata-only edit (the UI omits the key when left blank — "leave blank to keep existing") NULLed the stored key and broke the next poll. Mirror requests' UpdateIntegration: api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END, passing the raw trimmed string so a blank incoming ref keeps the existing value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): skip orphaned sources + add source delete endpoint An enabled source whose scan_source plugin was uninstalled/disabled kept its autoscan_sources row, which errored every poll cycle, and there was no way to remove it. DiscoverSources now returns the set of currently-discovered (installation_id, capability_id) pairs; PollOnce skips any enabled source not in that set quietly (no RecordError), stopping the per-cycle error spam for orphans. A nil set (no lister / discovery failed) disables pruning so a transient discovery failure does not silence live sources. Adds DELETE /admin/autoscan/sources/{id} -> HandleDeleteSource -> repo.DeleteSource so an operator can clear orphans (unknown id -> 404). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): reject reused connection when Requests integration is disabled RequestIntegrationLookup.Get returned a linked integration's base_url/api_key even when the integration was disabled or had a blank base_url (the v1 poll gate `WHERE ri.enabled = true` was dropped in v2). Now Get surfaces a disabled or unconfigured linked integration as an error, which the engine turns into a logged skip / RecordError instead of polling an unusable target. The gating is extracted into a pure checkRequestIntegrationUsable helper so it is unit-testable without a DB-backed repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): reschedule poll task on settings change HandleUpdateSettings no longer rescheduled the poll task (the v1 triggerUpdater / UpdateTriggers wiring was dropped in v2), so a default_poll_interval_seconds change only applied after a restart. Re-add an optional triggerUpdater (taskmanager.UpdateTriggers) on AutoscanHandler, wired via SetTriggerUpdater from the router when a task manager is available. On a successful settings update the handler recomputes the interval trigger from default_poll_interval_seconds and calls UpdateTriggers("autoscan_poll", ...). The dependency is optional: a nil updater skips rescheduling so tests need no task manager, and a reschedule failure is non-fatal (the interval is persisted). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): disable enable toggle for unbound sources, add source delete + interval hint - Disable the Enable switch when a source has no effective bound connection (connection_id null and no pending edit selection), re-enabling once bound. - Add useDeleteAutoscanSource hook mirroring useDeleteAutoscanConnection pattern. - Add per-row delete button (Trash2 icon → AlertDialog confirm) to let operators remove orphaned/unwanted source rows. - Add interval floor helper text showing the global default poll interval so operators know values below it have no effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): consume source_paths from merged scan_source contract The merged plugin SDK renamed PollChangesResponse.changed_paths to source_paths and the plugin now returns RAW source-namespace paths. pluginProvider.PollChanges reads GetSourcePaths(); the host applies per-source path rewrites before resolving/enqueueing (separate commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(migrations): add path_rewrites to autoscan_sources Add path_rewrites jsonb NOT NULL DEFAULT '[]' to the autoscan_sources CREATE in migration 172 (unreleased/branch-only, so amended in place). The host now owns per-source prefix rewrites. v1 path_rewrites cannot be backfilled (v2 sources key on a plugin installation/capability with no v1 mapping); documented that operators must re-enter rewrites post-upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): host-owned per-source path rewrites Rewrite ownership moved from the scan_source plugin to the host. The plugin returns raw source-namespace paths; the host now normalizes separators and applies the source's per-source prefix rewrites before dedupe/resolve/enqueue. - types: add PathRewrite{From,To} and Source.PathRewrites - rewrite: re-add applyRewrites/normalizeSeparators; apply the MOST-SPECIFIC (longest From) match, not first-match, so a broad rule can't shadow a nested one regardless of ordering - service.PollOnce: rewrite raw provider paths before resolveAndClaim - repository: marshal/unmarshal path_rewrites jsonb in UpsertSource and all source scans (EnsureSource discovery rows take the DB default []) - handlers: autoscanSourceInput/response + status DTO carry path_rewrites (full-state like connection_id); reject blank from/to with 400 - tests: rewrite unit tests, engine applies rewrites before enqueue, handler round-trips path_rewrites and 400s on a blank rewrite Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): discover installed scan_source plugins on sources-list view A scan_source plugin installed via the normal /admin/plugins flow must show up in the Autoscan component immediately, not only after a poll cycle (which runs only when autoscan is enabled). HandleListSources now runs discovery (seeding a disabled, connection-less source row per installed scan_source capability) before listing. Best-effort: discovery failure does not block listing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): per-source path rewrites editor + plugins-page install hint Add AutoscanPathRewrite type and path_rewrites fields to AutoscanSource/ AutoscanSourceInput. SourcesPanel gains an expandable rewrite editor per source row (from→to pairs, Add/Remove/Save) threaded into the full-state body so connection, interval, and rewrite changes always carry all fields. Adds a Plugins-page install hint in both the empty state and above the table for discoverability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(autoscan): host-owned path rewrites + install/discovery flow Reconcile the spec with the merged SDK decision (rewrites moved host-side; PollChangesResponse.source_paths carries raw provider paths). Document that scan-source plugins install via the normal /admin/plugins page and surface in Autoscan via discovery (run on poll cycles and on sources-list view). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build: depend on merged silo-plugin-sdk via pseudo-version (drop local replace) PR #2 (scan_source.v1 + source_paths) is merged to silo-plugin-sdk main, so the host can resolve the canonical module at the merged commit (v0.4.1-0.20260603030807-807b07e785b2) instead of a local-path replace. The branch now builds off-machine (CI/Docker). Bump to a clean v0.5.0 once tagged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(migrations): single clean autoscan v2 migration (v1 never shipped) The v1 in-process autoscan (migration 171) was never released to origin/main, so no live system has v1 autoscan data to preserve. Collapse the v1-create + v2-rename/backfill/drop dance into one clean 171 that creates the v2 connections-based schema directly. Removes 172 entirely. The runner applies by version set-difference with no checksum validation, so the already-migrated test instance (171+172 recorded) skips both and is unaffected; fresh installs get the clean v2 schema in one step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): allow many sources per plugin + add-source enumeration Drop the one-source-per-(installation, capability) model. A single installed scan_source plugin capability can now back many sources, each bound to a different connection (e.g. one Sonarr plugin fronting four arr servers). - migration 171: remove the autoscan_sources UNIQUE(installation_id, capability_id) constraint; sources are operator-created, not auto-seeded. - repository: replace UpsertSource (relied on the unique conflict) with a plain CreateSource (fresh uuid) + a by-id UpdateSource; remove EnsureSource. - discovery: replace auto-seeding (DiscoverSources/RefreshDiscovered) with ListAvailableScanSources (the Add-source picker list, enriched with plugin id + display name) and an installedScanSources set used only for orphan-skip. - service: PollOnce stops seeding and instead fetches the installed-capability set for orphan detection; Store gains GetSource and drops EnsureSource. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): connection test endpoint (engine) Add Service.TestConnection / TestConnectionByID: resolve a connection (ad-hoc input or an existing stored connection) to concrete credentials and probe the arr GET /api/v3/system/status with a short timeout. A reachable/authorized target yields OK=true plus the reported version; an unreachable / 401 / non-200 target yields OK=false with a human-readable error (the probe failure is part of the result payload, never an error from the method itself). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): host-side rewrite suggester + admin API for new endpoints Port the path-rewrite suggester back host-side (it had moved into the plugin): suggestRewrites suffix-matches arr root folders against Silo media folders to propose path rewrites, reporting proposed / unmatched / ambiguous / covered. Service.SuggestRewrites resolves the source's bound connection, lists arr roots (GET /api/v3/rootfolder) and Silo folder paths, and runs the matcher; a source with no bound connection returns ErrNoConnection (400). Admin API (all admin-gated): - POST /admin/autoscan/sources create a source - GET /admin/autoscan/scan-source-plugins Add-source picker list - POST /admin/autoscan/connections/test probe a connection - GET /admin/autoscan/sources/{id}/rewrite-suggestions sync rewrites HandleListSources no longer auto-seeds; create validates the capability is currently installed and that enabling requires a connection. Wiring threads the arr root-folder/status client and the catalog folder lister through BuildAutoscanService; the lister now surfaces plugin id + display name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan hooks + types for sources, connection test, rewrites Add types and React Query hooks backing the autoscan admin UI batch: - AutoscanAvailableSource / useAvailableScanSources (scan-source plugins) - AutoscanSourceCreateInput / useCreateAutoscanSource (POST sources) - AutoscanConnectionTestResult / useTestAutoscanConnection (advisory test) - AutoscanRewriteSuggestions / useAutoscanRewriteSuggestions (on-demand) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): add-source dialog + sync-from-arr rewrites in SourcesPanel Add a "+ Add source" header action opening a dialog that creates a scan source from any installed scan-source plugin bound to an arr connection, so operators can add one source per connection (e.g. four arr instances). Empty state links to /admin/plugins when no plugins are installed. Add a "Sync from arr" button to each source's rewrite editor that fetches root-folder rewrite suggestions and renders a preview: checkbox-selectable Proposed rewrites plus collapsed Unmatched / Ambiguous / Already-mapped sections. "Apply selected" merges the checked rewrites (dedupe by `from`) and persists via the normal full-state source PUT. Sync is disabled until the source has a bound connection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): test-connection button in autoscan ConnectionsPanel dialog Add an advisory "Test connection" button to the add/edit connection dialog. It probes the current dialog input — connection_id when editing, request_integration_id in reuse mode, or base_url/api_key_ref for own credentials — and renders the result inline: green "Connected (vX.Y)" on success, red error on failure. Never blocks save; stale results clear when credential fields change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): autoscan page polish + global enable toggle in header Surface a global Autoscan enable toggle and an enabled/disabled status badge next to the page title, alongside the existing "Run now" header action so primary controls are reachable without opening a tab. Remove the now-redundant enable switch from the Settings tab (it points at the header toggle instead). Tighten header layout for wrap on narrow widths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): right-align autoscan enable toggle + Run now in the page header Drop the redundant nested justify-between wrapper so the header actions sit directly under .page-header (space-between + bottom-align), matching the /admin/libraries header layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): hold poll marker when paths return but none resolve A freshly-enabled source whose path_rewrites aren't configured yet returns provider paths that resolve to zero library folders. PollOnce previously advanced the marker unconditionally on any successful poll, permanently skipping those imports. Now the marker advances only when there is nothing to do (zero paths) or at least one path resolved+enqueued; when paths come back but none resolve, the marker is held and an explaining error recorded so the operator can fix the rewrites and a later poll re-reads the same window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): don't prune sources of disabled-but-installed plugins PluginScanSourceLister used the installation store's ListEnabled, so a temporarily-disabled plugin dropped out of the discovered set and PollOnce treated its sources as orphaned, skipping them with no last_error (silent vanish). Switch to List so only a fully-uninstalled plugin counts as orphaned; a disabled-but-installed plugin's sources are still attempted and surface a visible RecordError when the client fails to load. The Add-source picker shares the same all-installed set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): treat empty request_integration_id as no link ConnectionResolver.Resolve gated the linked-integration path on a non-nil RequestIntegrationID pointer, so a pointer-to-empty-string (from a both-NULL orphan or a stripped link) called requests.Get(""). Guard on a non-empty trimmed value so it falls back to the connection's own fields instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): align startup poll interval with reschedule computation Startup seeded the poll task by integer-dividing default_poll_interval_seconds by 60 (minutes), while HandleUpdateSettings reschedules with seconds*1000 ms; the two diverged for sub-minute and non-60-multiple intervals. NewAutoscanPollTask now takes the interval in milliseconds and main.go seeds it as seconds*1000, matching the reschedule path so both agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): normalize stored rewrite From at poll time applyRewrites matched the stored From after only TrimSpace/TrimSuffix, while suggest.go coveredBy normalizes via normalizePath (backslash->slash, collapse '//'). A Windows-style or dup-slash stored rewrite was thus reported 'covered' at suggest time yet never matched at poll time. applyRewrites now normalizes From through normalizePath so poll-time and suggest-time agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): don't corrupt source poll interval on enable/connection change Add a `parseInterval` helper that maps empty input to null (use global default), valid positive integers to the integer, and any other mid-edit-invalid value to the source's currently-persisted `poll_interval_seconds` — so toggling the enable switch or changing the connection cannot silently overwrite the interval with 0 or NaN. Wire the helper through `fullBody()` (the single source of truth for PUT payloads) and remove the two inline duplications in `handleConnectionChange` and `handleRewriteSave` that both previously used raw `Number()`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): make the source connection optional (provider-agnostic) A host connection is the credential/endpoint for server-based providers (Sonarr/Radarr); other scan_source providers (e.g. a CephFS/filesystem watcher that reads ceph.dir.r* xattrs) need none. PollOnce now polls connection-less sources, passing an empty ResolvedConnection the plugin may ignore; a plugin that requires credentials surfaces the error at poll time. Drops the enable-requires-connection 400s. Provider-specific config lives in the plugin's own global_config_schema, not a host connection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): provider-agnostic autoscan copy + optional source connection Replace arr-hardcoded framing in AdminAutoscan, SourcesPanel, and ConnectionsPanel with neutral scan-source language. Remove the connection-required gate on the source enable toggle so connectionless providers (e.g. filesystem watchers) can be enabled; soften the badge from "Needs connection" to "No connection". Sync-from-server button remains gated on a bound connection (it needs a server to query). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: repo-relative paths in autoscan plans Replace local absolute filesystem paths (/opt/silo, sibling checkouts, /tmp/go/bin) in docs/superpowers/plans with repository-relative wording per CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): rune-safe last_error truncation Truncate RecordError messages on a UTF-8 rune boundary so a byte-bounded cut can't split a multi-byte rune and store invalid UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): advance marker when resolved-but-suppressed (not unresolved) resolveAndClaim now reports resolvedAny (whether any path mapped to a Silo library folder, independent of suppression). PollOnce gates the "none matched a Silo library folder" hold+RecordError on !resolvedAny instead of len(targets)==0, so a poll whose paths resolved but were all debounced/suppressed advances the marker instead of being treated as a misconfiguration. Adds a regression test for the suppressed case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(api): normalize request_integration_id Trim whitespace and collapse empty-after-trim request_integration_id to nil on connection create and update, so a pointer-to-"" or " " is never persisted as a bogus Requests link. Also corrects a stale migration-172 comment to 171 (the collapsed migration number). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(autoscan): provider-agnostic poll-task copy Rename the poll task to "Autoscan poll" with a provider-agnostic description and progress message; drop Sonarr/Radarr/arr wording. Key() (autoscan_poll) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(autoscan): fix typo in connectionless-source test name Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): add scan source management * chore(deps): bump silo-plugin-sdk for structured scan source changes Pins silo-plugin-sdk to 0d78651, which adds source_config on PollChangesRequest plus the structured changes / ScanSourceChangeScope fields on PollChangesResponse that internal/autoscan/provider.go already consumes. Without this the branch fails to compile against the prior pin (807b07e). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): label scan sources by connection name in admin UI arr-plugin sources fan out one-per-connection under a single generic "arr" capability, so every row in the Sources and Activity panels rendered an identical "arr (plugin #N)" label. Lead with the bound connection name (Radarr/Sonarr/...) instead, demoting capability + plugin to a subtitle. Sources without a connection (e.g. cephfs) keep the capability fallback. Activity threads a source_id -> connection name lookup (built from the existing sources + connections queries) through the scan/poll tables the same way librariesByID is threaded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(autoscan): spec for generic + operator-editable source labels Design for a shared label-resolution helper (operator label -> connection name -> manifest display_name -> capability_id) consumed by the Sources and Activity panels, plus an operator-editable per-source label backed by a new autoscan_sources.label column. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(autoscan): implementation plan for source labels Task-by-task TDD plan: migration 174 (label column), Go domain/repo/handler wiring with server-side normalization, shared frontend label helper, and Sources/Activity panel integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): migration for source label column * feat(autoscan): source label domain field + normalizer * feat(autoscan): persist source label in repository Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): accept, normalize, and return source label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): add label to source API types * feat(autoscan): shared source-label resolution helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(autoscan): polish source-label helper per review Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(autoscan): label sources via shared helper + operator label input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(autoscan): clarify source label naming per review * feat(autoscan): resolve activity source labels via shared helper Replace the sourceNames Map plumbing in ActivityPanel with SourceLabelLookups and delegate both name functions to resolveEventSourceName from @/lib/autoscanLabels, enabling the full label chain (operator label → connection name → manifest display_name → capability_id) for all Scan History and Poll log rows. * fix(autoscan): carry label on status source + guard poll label Final-review follow-ups: add the label field to the autoscanStatusSource response (and AutoscanStatusSource type) so the status view matches the source response per spec, and give pollSourceName a non-empty fallback for symmetry with scanSourceName. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(autoscan): resolve source aria-labels through the label chain Replace the legacy capability-only sourceLabel() helper with resolveSourceName() (operator label -> connection -> display_name -> capability). Row controls now announce the row's resolvedLabel (reflecting in-progress edits) and the delete dialog announces the resolved name, so screen readers hear "4K Movies" instead of "arr (plugin #4)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(autoscan): paginate queue + history with a shared table pager Replace the card/table hybrid and 200-row "Load more" cap on the autoscan Activity panel with proper tables and real pagination. Backend: add offset + total-count to the scans/events list endpoints so history pages through the full set instead of a capped window. Extract shared event/scan WHERE-clause builders so list and count filter identically, and add CountEvents / CountAutoscanScans. Frontend: add a reusable TablePagination component (rows-per-page, "showing X-Y of Z", numbered window with ellipses, responsive) and reuse it for the server-paginated history (scans + polls) and the client-paginated live queue. Unify all three tables behind one DataTable shell so they read as one family. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> * fix(migrations): renumber PR 48 migrations * fix(migrations): tolerate stale device profile ids --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: fluxis <warmasterx555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2544 lines
88 KiB
Go
2544 lines
88 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/adminjob"
|
|
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/cache"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/plugins"
|
|
"github.com/Silo-Server/silo-server/internal/scanner"
|
|
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
|
"github.com/Silo-Server/silo-server/internal/sections"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// LibraryHandler handles HTTP endpoints for library (media folder) management
|
|
// and scan triggering.
|
|
type LibraryHandler struct {
|
|
folderRepo *catalog.FolderRepository
|
|
ingester libraryIngester
|
|
userRepo *auth.UserRepository
|
|
pool *pgxpool.Pool
|
|
refresher AdminMetadataRefresher
|
|
chainCacheInvalidator interface{ InvalidateChainCache() }
|
|
JobRepo AdminJobCreator
|
|
ChainRepo *metadata.ChainRepository
|
|
PluginInstallations pluginInstallationLister
|
|
SkippedRootRepo *metadata.SkippedRootRepository
|
|
StaleIDRepo *metadata.StaleMediaIDRepository
|
|
MovieMatchQueueRepo libraryMovieMatchQueue
|
|
SeriesMatchQueueRepo librarySeriesMatchQueue
|
|
RawMatchBacklogRepo libraryRawMatchBacklog
|
|
TVSeriesRootQueue bool
|
|
ScannedGroupRepo *scanner.ScannedGroupRepository
|
|
GroupOverrideRepo *scanner.MediaGroupOverrideRepository
|
|
ObservedLocationRepo *scanner.ObservedLocationRepository
|
|
SectionRepo *sections.Repository
|
|
StoreProvider userstore.UserStoreProvider
|
|
S3Meta LibraryImageStore
|
|
PresignTTL time.Duration
|
|
appCtx context.Context
|
|
EventBus cache.EventBus
|
|
EventsHub *evt.Hub
|
|
ScanRegistry *evt.ScanRegistry
|
|
ScanQueue libraryScanQueuer
|
|
}
|
|
|
|
// LibraryImageStore provides S3 operations for library poster images.
|
|
type LibraryImageStore interface {
|
|
PutObject(ctx context.Context, bucket, key string, data []byte) error
|
|
DeleteObject(ctx context.Context, bucket, key string) error
|
|
PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
|
|
Bucket() string
|
|
}
|
|
|
|
// pluginInstallationLister provides access to plugin installations and capabilities
|
|
// for seeding default provider chains from manifest metadata.
|
|
type pluginInstallationLister interface {
|
|
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
|
|
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
|
|
}
|
|
|
|
type libraryIngester interface {
|
|
IngestFolder(ctx context.Context, folder *models.MediaFolder) (*libraryingest.Result, error)
|
|
IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error)
|
|
IngestFile(ctx context.Context, folder *models.MediaFolder, filePath string) (*libraryingest.Result, error)
|
|
CancelLibrary(folderID int) int
|
|
}
|
|
|
|
type libraryScanQueuer interface {
|
|
EnqueueLibraryScan(ctx context.Context, folderID int, trigger string) (bool, error)
|
|
EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error)
|
|
CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error)
|
|
CancelByLibrary(ctx context.Context, libraryID int) (int, error)
|
|
}
|
|
|
|
type libraryMovieMatchQueue interface {
|
|
SyncForFolder(ctx context.Context, folderID int) error
|
|
DeleteByFolder(ctx context.Context, folderID int) (int, error)
|
|
CountByFolder(ctx context.Context, folderID int) (int, error)
|
|
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error)
|
|
}
|
|
|
|
type librarySeriesMatchQueue interface {
|
|
SyncForFolder(ctx context.Context, folderID int) error
|
|
DeleteByFolder(ctx context.Context, folderID int) (int, error)
|
|
CountByFolder(ctx context.Context, folderID int) (int, error)
|
|
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error)
|
|
}
|
|
|
|
type libraryRawMatchBacklog interface {
|
|
CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
|
ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error)
|
|
SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
|
RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
|
|
}
|
|
|
|
// NewLibraryHandler creates a new LibraryHandler backed by the given folder
|
|
// repository and ingest executor. The ingester may be nil if scan endpoints are not needed.
|
|
func NewLibraryHandler(
|
|
folderRepo *catalog.FolderRepository,
|
|
ingester libraryIngester,
|
|
userRepo *auth.UserRepository,
|
|
pool *pgxpool.Pool,
|
|
refresher AdminMetadataRefresher,
|
|
appCtx ...context.Context,
|
|
) *LibraryHandler {
|
|
ctx := context.Background()
|
|
if len(appCtx) > 0 && appCtx[0] != nil {
|
|
ctx = appCtx[0]
|
|
}
|
|
var scannedGroupRepo *scanner.ScannedGroupRepository
|
|
var groupOverrideRepo *scanner.MediaGroupOverrideRepository
|
|
var observedLocationRepo *scanner.ObservedLocationRepository
|
|
if pool != nil {
|
|
scannedGroupRepo = scanner.NewScannedGroupRepository(pool)
|
|
groupOverrideRepo = scanner.NewMediaGroupOverrideRepository(pool)
|
|
observedLocationRepo = scanner.NewObservedLocationRepository(pool)
|
|
}
|
|
return &LibraryHandler{
|
|
folderRepo: folderRepo,
|
|
ingester: ingester,
|
|
userRepo: userRepo,
|
|
pool: pool,
|
|
refresher: refresher,
|
|
ScannedGroupRepo: scannedGroupRepo,
|
|
GroupOverrideRepo: groupOverrideRepo,
|
|
ObservedLocationRepo: observedLocationRepo,
|
|
appCtx: ctx,
|
|
}
|
|
}
|
|
|
|
func (h *LibraryHandler) SetChainCacheInvalidator(invalidator interface{ InvalidateChainCache() }) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.chainCacheInvalidator = invalidator
|
|
}
|
|
|
|
// validMetadataLanguages is the set of ISO 639-1 codes accepted for
|
|
// per-library metadata language. Kept in sync with the frontend LANGUAGES list.
|
|
var validMetadataLanguages = map[string]bool{
|
|
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
|
|
"nl": true, "pl": true, "ru": true, "zh": true, "ja": true, "ko": true,
|
|
"ar": true, "tr": true, "sv": true, "da": true, "no": true, "fi": true,
|
|
"hu": true, "cs": true, "ro": true, "he": true, "th": true, "vi": true,
|
|
"el": true, "bg": true, "hr": true, "sk": true, "sl": true, "uk": true,
|
|
"id": true, "ms": true, "hi": true, "ta": true, "te": true, "bn": true,
|
|
"fa": true,
|
|
}
|
|
|
|
// --- Request/Response types ---
|
|
|
|
// createLibraryRequest represents the JSON body for POST /libraries.
|
|
type createLibraryRequest struct {
|
|
Paths []string `json:"paths"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
MetadataLanguage string `json:"metadata_language,omitempty"`
|
|
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled,omitempty"`
|
|
IntroDetectionEnabled bool `json:"intro_detection_enabled,omitempty"`
|
|
}
|
|
|
|
// updateLibraryRequest represents the JSON body for PUT /libraries/{id}.
|
|
type updateLibraryRequest struct {
|
|
Paths *[]string `json:"paths,omitempty"`
|
|
Type *string `json:"type,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
MetadataLanguage *string `json:"metadata_language,omitempty"`
|
|
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
|
|
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
|
|
}
|
|
|
|
// scanRequest represents the JSON body for POST /scan.
|
|
type scanRequest struct {
|
|
LibraryID *int `json:"library_id,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
}
|
|
|
|
type scanResponse struct {
|
|
Status string `json:"status"`
|
|
Mode string `json:"mode"`
|
|
LibraryID int `json:"library_id"`
|
|
}
|
|
|
|
// scanCancelRequest represents the JSON body for POST /scan/cancel.
|
|
type scanCancelRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
}
|
|
|
|
type scanCancelResponse struct {
|
|
Cancelled int `json:"cancelled"`
|
|
LibraryID int `json:"library_id"`
|
|
}
|
|
|
|
// libraryResponse represents a library (media folder) in JSON responses.
|
|
type libraryResponse struct {
|
|
ID int `json:"id"`
|
|
Paths []string `json:"paths"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
MetadataLanguage string `json:"metadata_language"`
|
|
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
|
|
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
|
|
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
LastScannedAt *time.Time `json:"last_scanned_at,omitempty"`
|
|
ScanWarningCode *string `json:"scan_warning_code,omitempty"`
|
|
ScanWarningMessage *string `json:"scan_warning_message,omitempty"`
|
|
ScanWarningAt *time.Time `json:"scan_warning_at,omitempty"`
|
|
}
|
|
|
|
type libraryMountCheckRootResponse struct {
|
|
Path string `json:"path"`
|
|
Reachable bool `json:"reachable"`
|
|
ErrorCode *string `json:"error_code"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
}
|
|
|
|
type libraryMountCheckResponse struct {
|
|
Status string `json:"status"`
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
Healthy bool `json:"healthy"`
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
Summary string `json:"summary"`
|
|
Roots []libraryMountCheckRootResponse `json:"roots"`
|
|
}
|
|
|
|
type librarySkippedRootResponse struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
RootPath string `json:"root_path"`
|
|
Reason string `json:"reason"`
|
|
SampleFilePath string `json:"sample_file_path"`
|
|
FileCount int `json:"file_count"`
|
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
}
|
|
|
|
type staleMediaIDResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
ContentType string `json:"content_type"`
|
|
Provider string `json:"provider"`
|
|
ProviderID string `json:"provider_id"`
|
|
FirstSeenAt string `json:"first_seen_at"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
}
|
|
|
|
type libraryRootResponse struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
RootPath string `json:"root_path"`
|
|
State string `json:"state"`
|
|
InferredType string `json:"inferred_type"`
|
|
TypeConfidence string `json:"type_confidence"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
TmdbID string `json:"tmdb_id,omitempty"`
|
|
ImdbID string `json:"imdb_id,omitempty"`
|
|
TvdbID string `json:"tvdb_id,omitempty"`
|
|
ObservedFiles int `json:"observed_file_count"`
|
|
SampleFilePath string `json:"sample_file_path,omitempty"`
|
|
Evidence json.RawMessage `json:"evidence_json,omitempty"`
|
|
OverrideSource string `json:"override_source,omitempty"`
|
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
ActiveOverride *rootOverride `json:"active_override,omitempty"`
|
|
}
|
|
|
|
type rootOverride struct {
|
|
ForcedType string `json:"forced_type,omitempty"`
|
|
ForcedTitle string `json:"forced_title,omitempty"`
|
|
ForcedYear int `json:"forced_year,omitempty"`
|
|
ForcedTmdbID string `json:"forced_tmdb_id,omitempty"`
|
|
ForcedImdbID string `json:"forced_imdb_id,omitempty"`
|
|
ForcedTvdbID string `json:"forced_tvdb_id,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type libraryRootsListResponse struct {
|
|
Items []libraryRootResponse `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type rootOverrideUpsertRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
RootPath string `json:"root_path"`
|
|
ForcedType string `json:"forced_type,omitempty"`
|
|
ForcedTitle string `json:"forced_title,omitempty"`
|
|
ForcedYear int `json:"forced_year,omitempty"`
|
|
ForcedTmdbID string `json:"forced_tmdb_id,omitempty"`
|
|
ForcedImdbID string `json:"forced_imdb_id,omitempty"`
|
|
ForcedTvdbID string `json:"forced_tvdb_id,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type rootOverrideDeleteRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
RootPath string `json:"root_path"`
|
|
}
|
|
|
|
func groupOverrideLookupKey(groupKeyVersion int, contentGroupKey string) string {
|
|
return strconv.Itoa(groupKeyVersion) + "|" + contentGroupKey
|
|
}
|
|
|
|
// toLibraryResponse converts a MediaFolder model to a libraryResponse.
|
|
func toLibraryResponse(f *models.MediaFolder) libraryResponse {
|
|
paths := f.Paths
|
|
if paths == nil {
|
|
paths = []string{}
|
|
}
|
|
return libraryResponse{
|
|
ID: f.ID,
|
|
Paths: paths,
|
|
Type: f.Type,
|
|
Name: f.Name,
|
|
Enabled: f.Enabled,
|
|
MetadataLanguage: f.MetadataLanguage,
|
|
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
|
|
ChapterThumbnailsSupported: false,
|
|
IntroDetectionEnabled: f.IntroDetectionEnabled,
|
|
SortOrder: f.SortOrder,
|
|
LastScannedAt: f.LastScannedAt,
|
|
ScanWarningCode: f.ScanWarningCode,
|
|
ScanWarningMessage: f.ScanWarningMessage,
|
|
ScanWarningAt: f.ScanWarningAt,
|
|
}
|
|
}
|
|
|
|
// toLibraryResponseWithPoster converts a MediaFolder model to a libraryResponse
|
|
// and presigns the poster URL if a poster path is set.
|
|
func (h *LibraryHandler) toLibraryResponseWithPoster(ctx context.Context, f *models.MediaFolder) libraryResponse {
|
|
resp := toLibraryResponse(f)
|
|
resp.ChapterThumbnailsSupported = h.S3Meta != nil
|
|
if f.PosterPath != "" && h.S3Meta != nil {
|
|
ttl := h.PresignTTL
|
|
if ttl <= 0 {
|
|
ttl = 4 * time.Hour
|
|
}
|
|
url, err := h.S3Meta.PresignGetURL(ctx, h.S3Meta.Bucket(), f.PosterPath, ttl)
|
|
if err == nil {
|
|
resp.PosterURL = url
|
|
}
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// userLibraryResponse is a simplified library view for non-admin users.
|
|
type userLibraryResponse struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
SortOrder int `json:"sort_order"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
}
|
|
|
|
// --- Handler methods ---
|
|
|
|
// HandleListUserLibraries handles GET /user/libraries.
|
|
// It returns only enabled libraries the current user has access to, with
|
|
// simplified fields (no paths, last scan metadata, etc.).
|
|
func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.Request) {
|
|
var folders []*models.MediaFolder
|
|
var err error
|
|
if scope, ok := access.GetScope(r.Context()); ok {
|
|
if scope.LibrariesRestricted {
|
|
folders, err = h.folderRepo.ListByIDs(r.Context(), scope.AllowedLibraryIDs)
|
|
} else {
|
|
folders, err = h.folderRepo.GetEnabled(r.Context())
|
|
}
|
|
} else {
|
|
userID := apimw.GetUserID(r.Context())
|
|
|
|
if h.userRepo != nil {
|
|
user, userErr := h.userRepo.GetByID(r.Context(), userID)
|
|
if userErr != nil {
|
|
slog.Error("looking up user for library access", "error", userErr)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up user")
|
|
return
|
|
}
|
|
|
|
if user.LibraryIDs != nil {
|
|
folders, err = h.folderRepo.ListByIDs(r.Context(), user.LibraryIDs)
|
|
} else {
|
|
folders, err = h.folderRepo.GetEnabled(r.Context())
|
|
}
|
|
} else {
|
|
folders, err = h.folderRepo.GetEnabled(r.Context())
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
slog.Error("listing user libraries", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
|
return
|
|
}
|
|
|
|
resp := make([]userLibraryResponse, 0, len(folders))
|
|
for _, f := range folders {
|
|
entry := userLibraryResponse{
|
|
ID: f.ID,
|
|
Name: f.Name,
|
|
Type: f.Type,
|
|
SortOrder: f.SortOrder,
|
|
}
|
|
if f.PosterPath != "" && h.S3Meta != nil {
|
|
ttl := h.PresignTTL
|
|
if ttl <= 0 {
|
|
ttl = 4 * time.Hour
|
|
}
|
|
if url, err := h.S3Meta.PresignGetURL(r.Context(), h.S3Meta.Bucket(), f.PosterPath, ttl); err == nil {
|
|
entry.PosterURL = url
|
|
}
|
|
}
|
|
resp = append(resp, entry)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleListLibraries handles GET /libraries.
|
|
func (h *LibraryHandler) HandleListLibraries(w http.ResponseWriter, r *http.Request) {
|
|
folders, err := h.folderRepo.List(r.Context())
|
|
if err != nil {
|
|
slog.Error("listing libraries", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
|
return
|
|
}
|
|
|
|
resp := make([]libraryResponse, 0, len(folders))
|
|
for _, f := range folders {
|
|
resp = append(resp, h.toLibraryResponseWithPoster(r.Context(), f))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// reorderLibrariesRequest is the JSON body for PUT /libraries/reorder.
|
|
type reorderLibrariesRequest struct {
|
|
Entries []catalog.FolderReorderEntry `json:"entries"`
|
|
}
|
|
|
|
// HandleReorderLibraries handles PUT /libraries/reorder.
|
|
func (h *LibraryHandler) HandleReorderLibraries(w http.ResponseWriter, r *http.Request) {
|
|
var req reorderLibrariesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if err := h.folderRepo.Reorder(r.Context(), req.Entries); err != nil {
|
|
slog.Error("reordering libraries", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder libraries")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleListSkippedRoots handles GET /libraries/skipped-roots.
|
|
func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.Request) {
|
|
if h.SkippedRootRepo == nil {
|
|
writeJSON(w, http.StatusOK, []librarySkippedRootResponse{})
|
|
return
|
|
}
|
|
|
|
folders, err := h.folderRepo.List(r.Context())
|
|
if err != nil {
|
|
slog.Error("listing libraries for skipped roots", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries")
|
|
return
|
|
}
|
|
|
|
folderNames := make(map[int]string, len(folders))
|
|
for _, folder := range folders {
|
|
folderNames[folder.ID] = folder.Name
|
|
}
|
|
|
|
roots, err := h.SkippedRootRepo.ListAll(r.Context())
|
|
if err != nil {
|
|
slog.Error("listing skipped roots", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list skipped roots")
|
|
return
|
|
}
|
|
|
|
resp := make([]librarySkippedRootResponse, 0, len(roots))
|
|
for _, root := range roots {
|
|
resp = append(resp, librarySkippedRootResponse{
|
|
LibraryID: root.MediaFolderID,
|
|
LibraryName: folderNames[root.MediaFolderID],
|
|
RootPath: root.RootPath,
|
|
Reason: root.Reason,
|
|
SampleFilePath: root.SampleFilePath,
|
|
FileCount: root.FileCount,
|
|
FirstSeenAt: root.FirstSeenAt,
|
|
LastSeenAt: root.LastSeenAt,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleCreateLibrary handles POST /libraries.
|
|
func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Request) {
|
|
var req createLibraryRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.Paths) == 0 || req.Type == "" || req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Paths, type, and name are required")
|
|
return
|
|
}
|
|
if req.MetadataLanguage != "" && !validMetadataLanguages[req.MetadataLanguage] {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code")
|
|
return
|
|
}
|
|
if req.ChapterThumbnailsEnabled && h.S3Meta == nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage")
|
|
return
|
|
}
|
|
|
|
folder, err := h.folderRepo.Create(r.Context(), catalog.CreateFolderInput{
|
|
Paths: req.Paths,
|
|
Type: req.Type,
|
|
Name: req.Name,
|
|
MetadataLanguage: req.MetadataLanguage,
|
|
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
|
|
IntroDetectionEnabled: req.IntroDetectionEnabled,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrDuplicatePath) {
|
|
writeError(w, http.StatusConflict, "conflict", "A library with this path already exists")
|
|
return
|
|
}
|
|
slog.Error("creating library", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create library")
|
|
return
|
|
}
|
|
|
|
// Seed default sections for the new library.
|
|
if h.SectionRepo != nil {
|
|
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
|
|
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
|
|
}
|
|
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
|
|
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
|
|
}
|
|
}
|
|
|
|
// Seed default provider chain from plugin manifest defaults.
|
|
if h.ChainRepo != nil {
|
|
entries := h.seedDefaultChain(r.Context(), req.Type)
|
|
if len(entries) > 0 {
|
|
if seedErr := h.ChainRepo.SetChain(r.Context(), folder.ID, entries); seedErr != nil {
|
|
slog.Warn("seed default chain failed", "folder_id", folder.ID, "error", seedErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kick off an initial scan so content appears immediately.
|
|
if h.ScanQueue != nil {
|
|
if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_created"); err != nil {
|
|
slog.Warn("queue initial library scan failed", "library_id", folder.ID, "error", err)
|
|
}
|
|
} else {
|
|
initialScanID := ulid.Make().String()
|
|
h.recordAcceptedScan(initialScanID, &scantrigger.Target{
|
|
Folder: folder,
|
|
Mode: scantrigger.ModeLibrary,
|
|
Trigger: "library_created",
|
|
})
|
|
h.runFolderScanAsync(initialScanID, folder, "library_created")
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, h.toLibraryResponseWithPoster(r.Context(), folder))
|
|
}
|
|
|
|
// HandleUpdateLibrary handles PUT /libraries/{id}.
|
|
func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
var req updateLibraryRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.MetadataLanguage != nil && *req.MetadataLanguage != "" && !validMetadataLanguages[*req.MetadataLanguage] {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code")
|
|
return
|
|
}
|
|
if req.ChapterThumbnailsEnabled != nil && *req.ChapterThumbnailsEnabled && h.S3Meta == nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage")
|
|
return
|
|
}
|
|
|
|
// Fetch the folder before updating so we can detect path changes.
|
|
oldFolder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("fetching library for update", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
err = h.folderRepo.Update(r.Context(), id, catalog.UpdateFolderInput{
|
|
Paths: req.Paths,
|
|
Type: req.Type,
|
|
Name: req.Name,
|
|
Enabled: req.Enabled,
|
|
MetadataLanguage: req.MetadataLanguage,
|
|
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
|
|
IntroDetectionEnabled: req.IntroDetectionEnabled,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
if errors.Is(err, catalog.ErrDuplicatePath) {
|
|
writeError(w, http.StatusConflict, "conflict", "A library with this path already exists")
|
|
return
|
|
}
|
|
slog.Error("updating library", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update library")
|
|
return
|
|
}
|
|
|
|
// Fetch the updated folder to return it.
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("fetching updated library", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated library")
|
|
return
|
|
}
|
|
|
|
if h.SectionRepo != nil && oldFolder.Name != folder.Name {
|
|
if syncErr := h.SectionRepo.SyncGeneratedHomeLibraryRecentTitles(r.Context(), id, oldFolder.Name, folder.Name); syncErr != nil {
|
|
slog.Warn("sync generated home section titles", "library_id", id, "error", syncErr)
|
|
}
|
|
}
|
|
|
|
// Rescan when paths have changed (folders added or removed).
|
|
if req.Paths != nil && !slices.Equal(oldFolder.Paths, *req.Paths) {
|
|
if h.ScanQueue != nil {
|
|
if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_paths_changed"); err != nil {
|
|
slog.Warn("queue library path-change scan failed", "library_id", folder.ID, "error", err)
|
|
}
|
|
} else {
|
|
updateScanID := ulid.Make().String()
|
|
h.recordAcceptedScan(updateScanID, &scantrigger.Target{
|
|
Folder: folder,
|
|
Mode: scantrigger.ModeLibrary,
|
|
Trigger: "library_paths_changed",
|
|
})
|
|
h.runFolderScanAsync(updateScanID, folder, "library_paths_changed")
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder))
|
|
}
|
|
|
|
// HandleDeleteLibrary handles DELETE /libraries/{id}.
|
|
func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
if h.JobRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library delete jobs are not configured")
|
|
return
|
|
}
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("fetching library before delete", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library")
|
|
return
|
|
}
|
|
|
|
wasEnabled := folder.Enabled
|
|
if wasEnabled {
|
|
disabled := false
|
|
if err := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &disabled}); err != nil {
|
|
slog.Error("disabling library before delete", "library_id", folder.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare library deletion")
|
|
return
|
|
}
|
|
folder.Enabled = false
|
|
}
|
|
|
|
job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{
|
|
JobType: adminjob.JobTypeDeleteLibrary,
|
|
CreatedByUserID: currentAdminUserID(r),
|
|
RequestPayload: adminjob.DeleteLibraryRequest{
|
|
LibraryID: folder.ID,
|
|
LibraryName: folder.Name,
|
|
},
|
|
Message: "Queued library deletion",
|
|
})
|
|
if err != nil {
|
|
if wasEnabled {
|
|
enabled := true
|
|
if revertErr := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &enabled}); revertErr != nil {
|
|
slog.Error("re-enabling library after failed delete queue",
|
|
"library_id", folder.ID,
|
|
"queue_error", err,
|
|
"revert_error", revertErr,
|
|
)
|
|
}
|
|
}
|
|
var conflict *adminjob.ActiveJobConflictError
|
|
if errors.As(err, &conflict) {
|
|
jobsHandler := NewAdminJobsHandler(nil, nil)
|
|
writeAdminJobConflict(w, "A library deletion is already queued or running", conflict.Job, jobsHandler, r)
|
|
return
|
|
}
|
|
slog.Error("queuing library delete job", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library delete")
|
|
return
|
|
}
|
|
|
|
if h.ingester != nil {
|
|
cancelled := h.ingester.CancelLibrary(folder.ID)
|
|
slog.Info("library delete: cancelled running scans", "library_id", folder.ID, "cancelled", cancelled)
|
|
}
|
|
if h.ScanQueue != nil {
|
|
queuedCancelled, err := h.ScanQueue.CancelAcceptedByLibrary(r.Context(), folder.ID)
|
|
if err != nil {
|
|
slog.Warn("library delete: failed to cancel queued scans", "library_id", folder.ID, "error", err)
|
|
} else if queuedCancelled > 0 {
|
|
slog.Info("library delete: cancelled queued scans", "library_id", folder.ID, "cancelled", queuedCancelled)
|
|
}
|
|
}
|
|
publishEventJob(r.Context(), h.EventsHub, "job.created", job)
|
|
|
|
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil))
|
|
}
|
|
|
|
// HandleCheckLibraryMount handles POST /libraries/{id}/check-mount.
|
|
// It verifies that each configured library root exists and can be listed.
|
|
func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("fetching library for mount check", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
resp := checkLibraryMount(folder)
|
|
if resp.Healthy && folder.ScanWarningCode != nil && *folder.ScanWarningCode == "empty_root" {
|
|
if err := h.folderRepo.ClearScanWarning(r.Context(), folder.ID); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("clearing empty-root warning after successful mount check", "library_id", folder.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear library warning")
|
|
return
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleScan handles POST /scan. It accepts either a library_id, a path, or both
|
|
// and dispatches to full-library, subtree, or single-file scanning.
|
|
func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
|
|
var req scanRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{
|
|
LibraryID: req.LibraryID,
|
|
Path: req.Path,
|
|
})
|
|
if err != nil {
|
|
var reqErr *scantrigger.RequestError
|
|
if errors.As(err, &reqErr) {
|
|
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
|
|
return
|
|
}
|
|
slog.Error("resolving scan target", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target")
|
|
return
|
|
}
|
|
|
|
if h.ScanQueue != nil {
|
|
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
|
|
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
|
|
return
|
|
}
|
|
} else if h.ingester != nil {
|
|
scanID := ulid.Make().String()
|
|
h.recordAcceptedScan(scanID, target)
|
|
switch target.Mode {
|
|
case scantrigger.ModeFile:
|
|
h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
|
case scantrigger.ModeSubtree:
|
|
h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger)
|
|
default:
|
|
h.runFolderScanAsync(scanID, target.Folder, target.Trigger)
|
|
}
|
|
} else {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, scanResponse{
|
|
Status: "accepted",
|
|
Mode: target.Mode,
|
|
LibraryID: target.Folder.ID,
|
|
})
|
|
}
|
|
|
|
// HandleScanCancel handles POST /scan/cancel. It cancels all running scans
|
|
// for a given library.
|
|
func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request) {
|
|
var req scanCancelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.LibraryID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required")
|
|
return
|
|
}
|
|
if h.ingester == nil && h.ScanQueue == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
|
|
return
|
|
}
|
|
|
|
cancelled := 0
|
|
if h.ScanQueue != nil {
|
|
queuedCancelled, err := h.ScanQueue.CancelByLibrary(r.Context(), req.LibraryID)
|
|
if err != nil {
|
|
slog.Error("cancel library scans", "library_id", req.LibraryID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel scans")
|
|
return
|
|
}
|
|
cancelled += queuedCancelled
|
|
}
|
|
if h.ingester != nil {
|
|
cancelled += h.ingester.CancelLibrary(req.LibraryID)
|
|
}
|
|
for _, run := range h.cancelActiveScans(req.LibraryID) {
|
|
h.publishScanEvent(r.Context(), "scan.cancelled", run)
|
|
}
|
|
slog.Info("scan: cancelled running scans",
|
|
"library_id", req.LibraryID,
|
|
"cancelled", cancelled,
|
|
)
|
|
|
|
writeJSON(w, http.StatusOK, scanCancelResponse{
|
|
Cancelled: cancelled,
|
|
LibraryID: req.LibraryID,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) {
|
|
go func() {
|
|
h.markScanRunning(scanID)
|
|
slog.Info("scan: starting library scan",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"paths", folder.Paths,
|
|
)
|
|
|
|
start := time.Now()
|
|
|
|
result, ingestErr := h.ingester.IngestFolder(h.appCtx, folder)
|
|
if ingestErr != nil {
|
|
if errors.Is(ingestErr, context.Canceled) {
|
|
h.markScanCancelled(scanID)
|
|
slog.Info("scan: library scan canceled",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
return
|
|
}
|
|
h.markScanFailed(scanID, ingestErr)
|
|
slog.Error("scan: library ingest failed",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"paths", folder.Paths,
|
|
"error", ingestErr,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
return
|
|
}
|
|
h.markScanCompleted(scanID, result)
|
|
|
|
slog.Info("scan: library ingest complete",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }),
|
|
"updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }),
|
|
"unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }),
|
|
"missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }),
|
|
"files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }),
|
|
"memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }),
|
|
"items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }),
|
|
"empty_root_guarded", scanBoolMetric(result, func(r *scanner.ScanResult) bool { return r.EmptyRootGuarded }),
|
|
"errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }),
|
|
"matched_files", result.MatchedFiles,
|
|
"retried_items", result.RetriedItems,
|
|
"still_unmatched_warnings", result.StillUnmatchedWarnings,
|
|
"skipped", result.Skipped,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryHandler) runSubtreeScanAsync(scanID string, folder *models.MediaFolder, subtreePath, trigger string) {
|
|
go func() {
|
|
h.markScanRunning(scanID)
|
|
slog.Info("scan: starting subtree scan",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"path", subtreePath,
|
|
)
|
|
|
|
start := time.Now()
|
|
|
|
result, ingestErr := h.ingester.IngestSubtree(h.appCtx, folder, subtreePath)
|
|
if ingestErr != nil {
|
|
if errors.Is(ingestErr, context.Canceled) {
|
|
h.markScanCancelled(scanID)
|
|
slog.Info("scan: subtree scan canceled",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"path", subtreePath,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
return
|
|
}
|
|
h.markScanFailed(scanID, ingestErr)
|
|
slog.Error("scan: subtree ingest failed",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"path", subtreePath,
|
|
"error", ingestErr,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
return
|
|
}
|
|
h.markScanCompleted(scanID, result)
|
|
|
|
slog.Info("scan: subtree ingest complete",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"path", subtreePath,
|
|
"new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }),
|
|
"updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }),
|
|
"unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }),
|
|
"missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }),
|
|
"files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }),
|
|
"memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }),
|
|
"items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }),
|
|
"errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }),
|
|
"matched_files", result.MatchedFiles,
|
|
"retried_items", result.RetriedItems,
|
|
"still_unmatched_warnings", result.StillUnmatchedWarnings,
|
|
"skipped", result.Skipped,
|
|
"elapsed", time.Since(start).Round(time.Millisecond),
|
|
)
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFolder, filePath, trigger string) {
|
|
go func() {
|
|
h.markScanRunning(scanID)
|
|
slog.Info("scan: starting file scan",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"path", filePath,
|
|
)
|
|
|
|
result, ingestErr := h.ingester.IngestFile(h.appCtx, folder, filePath)
|
|
if ingestErr != nil {
|
|
if errors.Is(ingestErr, context.Canceled) {
|
|
h.markScanCancelled(scanID)
|
|
slog.Info("scan: file scan canceled",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"path", filePath,
|
|
)
|
|
return
|
|
}
|
|
h.markScanFailed(scanID, ingestErr)
|
|
slog.Error("scan: file ingest failed",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"path", filePath,
|
|
"error", ingestErr,
|
|
)
|
|
return
|
|
}
|
|
h.markScanCompleted(scanID, result)
|
|
|
|
slog.Info("scan: file ingest complete",
|
|
"trigger", trigger,
|
|
"library_id", folder.ID,
|
|
"name", folder.Name,
|
|
"path", filePath,
|
|
"matched_files", result.MatchedFiles,
|
|
"retried_items", result.RetriedItems,
|
|
"still_unmatched_warnings", result.StillUnmatchedWarnings,
|
|
"skipped", result.Skipped,
|
|
)
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) {
|
|
if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil {
|
|
return
|
|
}
|
|
h.ScanRegistry.Upsert(evt.ScanRun{
|
|
ID: scanID,
|
|
LibraryID: target.Folder.ID,
|
|
Mode: target.Mode,
|
|
Path: target.Path,
|
|
Trigger: target.Trigger,
|
|
Status: "accepted",
|
|
})
|
|
if run, ok := h.ScanRegistry.Get(scanID); ok {
|
|
h.publishScanEvent(context.Background(), "scan.accepted", run)
|
|
}
|
|
}
|
|
|
|
func (h *LibraryHandler) markScanRunning(scanID string) {
|
|
if h == nil || h.ScanRegistry == nil {
|
|
return
|
|
}
|
|
run, ok := h.ScanRegistry.Get(scanID)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
run.Status = "running"
|
|
run.StartedAt = &now
|
|
h.ScanRegistry.Upsert(run)
|
|
h.publishScanEvent(context.Background(), "scan.started", run)
|
|
}
|
|
|
|
func (h *LibraryHandler) markScanCompleted(scanID string, result *libraryingest.Result) {
|
|
if h == nil || h.ScanRegistry == nil {
|
|
return
|
|
}
|
|
run, ok := h.ScanRegistry.Get(scanID)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
run.Status = "completed"
|
|
run.CompletedAt = &now
|
|
run.Result = scanRunResultFromIngest(result)
|
|
h.ScanRegistry.MarkTerminal(run)
|
|
h.publishScanEvent(context.Background(), "scan.completed", run)
|
|
}
|
|
|
|
func (h *LibraryHandler) markScanFailed(scanID string, err error) {
|
|
if h == nil || h.ScanRegistry == nil {
|
|
return
|
|
}
|
|
run, ok := h.ScanRegistry.Get(scanID)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
run.Status = "failed"
|
|
run.CompletedAt = &now
|
|
if err != nil {
|
|
run.ErrorMessage = err.Error()
|
|
}
|
|
h.ScanRegistry.MarkTerminal(run)
|
|
h.publishScanEvent(context.Background(), "scan.failed", run)
|
|
}
|
|
|
|
func (h *LibraryHandler) markScanCancelled(scanID string) {
|
|
if h == nil || h.ScanRegistry == nil {
|
|
return
|
|
}
|
|
run, ok := h.ScanRegistry.Get(scanID)
|
|
if !ok {
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
run.Status = "cancelled"
|
|
run.CompletedAt = &now
|
|
h.ScanRegistry.MarkTerminal(run)
|
|
h.publishScanEvent(context.Background(), "scan.cancelled", run)
|
|
}
|
|
|
|
func (h *LibraryHandler) cancelActiveScans(libraryID int) []evt.ScanRun {
|
|
if h == nil || h.ScanRegistry == nil {
|
|
return nil
|
|
}
|
|
return h.ScanRegistry.CancelLibrary(libraryID, time.Now().UTC())
|
|
}
|
|
|
|
func (h *LibraryHandler) publishScanEvent(ctx context.Context, eventName string, run evt.ScanRun) {
|
|
if h == nil || h.EventsHub == nil {
|
|
return
|
|
}
|
|
_ = h.EventsHub.PublishJSON(ctx, evt.ChannelScans, eventName, run, evt.PublishOptions{
|
|
AdminOnly: true,
|
|
})
|
|
}
|
|
|
|
func scanRunResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult {
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
resp := &evt.ScanRunResult{
|
|
MatchedFiles: result.MatchedFiles,
|
|
RetriedItems: result.RetriedItems,
|
|
StillUnmatchedWarnings: result.StillUnmatchedWarnings,
|
|
}
|
|
if result.Skipped {
|
|
resp.Skipped = 1
|
|
}
|
|
if result.ScanResult != nil {
|
|
resp.New = result.ScanResult.New
|
|
resp.Updated = result.ScanResult.Updated
|
|
resp.Unchanged = result.ScanResult.Unchanged
|
|
resp.Missing = result.ScanResult.Missing
|
|
resp.FilesDeleted = result.ScanResult.FilesDeleted
|
|
resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved
|
|
resp.ItemsDeleted = result.ScanResult.ItemsDeleted
|
|
resp.Errors = result.ScanResult.Errors
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func scanMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) int) int {
|
|
if result == nil || result.ScanResult == nil {
|
|
return 0
|
|
}
|
|
return pick(result.ScanResult)
|
|
}
|
|
|
|
func scanBoolMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) bool) bool {
|
|
if result == nil || result.ScanResult == nil {
|
|
return false
|
|
}
|
|
return pick(result.ScanResult)
|
|
}
|
|
|
|
func checkLibraryMount(folder *models.MediaFolder) libraryMountCheckResponse {
|
|
resp := libraryMountCheckResponse{
|
|
Status: "ok",
|
|
LibraryID: folder.ID,
|
|
LibraryName: folder.Name,
|
|
Healthy: true,
|
|
CheckedAt: time.Now().UTC(),
|
|
Roots: make([]libraryMountCheckRootResponse, 0, len(folder.Paths)),
|
|
}
|
|
|
|
if len(folder.Paths) == 0 {
|
|
resp.Healthy = false
|
|
resp.Summary = "Library has no configured roots"
|
|
return resp
|
|
}
|
|
|
|
unreachable := 0
|
|
for _, path := range folder.Paths {
|
|
root := libraryMountCheckRootResponse{
|
|
Path: path,
|
|
Reachable: true,
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
code, message := classifyMountCheckError(err, false)
|
|
root.Reachable = false
|
|
root.ErrorCode = stringPtr(code)
|
|
root.ErrorMessage = stringPtr(message)
|
|
} else if !info.IsDir() {
|
|
root.Reachable = false
|
|
root.ErrorCode = stringPtr("not_directory")
|
|
root.ErrorMessage = stringPtr("Path is not a directory")
|
|
} else if _, err := os.ReadDir(path); err != nil {
|
|
code, message := classifyMountCheckError(err, true)
|
|
root.Reachable = false
|
|
root.ErrorCode = stringPtr(code)
|
|
root.ErrorMessage = stringPtr(message)
|
|
}
|
|
|
|
if !root.Reachable {
|
|
unreachable++
|
|
resp.Healthy = false
|
|
}
|
|
resp.Roots = append(resp.Roots, root)
|
|
}
|
|
|
|
switch unreachable {
|
|
case 0:
|
|
resp.Summary = "All configured roots are reachable"
|
|
case 1:
|
|
resp.Summary = fmt.Sprintf("1 of %d roots unreachable", len(folder.Paths))
|
|
default:
|
|
resp.Summary = fmt.Sprintf("%d of %d roots unreachable", unreachable, len(folder.Paths))
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func classifyMountCheckError(err error, isRead bool) (string, string) {
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return "not_found", "Path does not exist"
|
|
case errors.Is(err, os.ErrPermission):
|
|
return "permission_denied", "Permission denied"
|
|
case isRead:
|
|
return "read_failed", "Failed to read directory"
|
|
default:
|
|
return "stat_failed", "Failed to stat path"
|
|
}
|
|
}
|
|
|
|
func stringPtr(v string) *string {
|
|
return &v
|
|
}
|
|
|
|
func boolPtr(v bool) *bool {
|
|
return &v
|
|
}
|
|
|
|
func (h *LibraryHandler) publishCatalogStatsInvalidation(eventType, payload string) {
|
|
if h.EventBus == nil {
|
|
return
|
|
}
|
|
if err := h.EventBus.Publish(h.appCtx, cache.ChannelCatalog, cache.Event{Type: eventType, Payload: payload}); err != nil {
|
|
slog.Warn("scan: failed to publish catalog invalidation event",
|
|
"type", eventType,
|
|
"payload", payload,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
type refreshLibraryMetadataRequest struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
type libraryMetadataMatchQueueStatusResponse struct {
|
|
LibraryID int `json:"library_id"`
|
|
MovieCount int `json:"movie_count"`
|
|
SeriesCount int `json:"series_count"`
|
|
RawFileCount int `json:"raw_file_count"`
|
|
TotalCount int `json:"total_count"`
|
|
}
|
|
|
|
type libraryMetadataMatchQueueActionResponse struct {
|
|
Status string `json:"status"`
|
|
LibraryID int `json:"library_id"`
|
|
MovieCancelled int `json:"movie_cancelled,omitempty"`
|
|
SeriesCancelled int `json:"series_cancelled,omitempty"`
|
|
RawFileCancelled int `json:"raw_file_cancelled,omitempty"`
|
|
RawFileRetried int `json:"raw_file_retried,omitempty"`
|
|
TotalCancelled int `json:"total_cancelled,omitempty"`
|
|
Queue libraryMetadataMatchQueueStatusResponse `json:"queue"`
|
|
}
|
|
|
|
type libraryMetadataMatchQueueDetailResponse struct {
|
|
libraryMetadataMatchQueueStatusResponse
|
|
Movies []libraryMovieMatchQueueEntryResponse `json:"movies"`
|
|
Series []librarySeriesMatchQueueEntryResponse `json:"series"`
|
|
RawFiles []libraryRawMatchBacklogEntryResponse `json:"raw_files"`
|
|
}
|
|
|
|
type libraryMovieMatchQueueEntryResponse struct {
|
|
MediaFileID int `json:"media_file_id"`
|
|
MediaFolderID int `json:"media_folder_id"`
|
|
FilePath string `json:"file_path"`
|
|
FirstQueuedAt time.Time `json:"first_queued_at"`
|
|
AvailableAt time.Time `json:"available_at"`
|
|
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
|
AttemptCount int `json:"attempt_count"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type librarySeriesMatchQueueEntryResponse struct {
|
|
MediaFolderID int `json:"media_folder_id"`
|
|
ObservedRootPath string `json:"observed_root_path"`
|
|
FirstQueuedAt time.Time `json:"first_queued_at"`
|
|
AvailableAt time.Time `json:"available_at"`
|
|
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
|
AttemptCount int `json:"attempt_count"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type libraryRawMatchBacklogEntryResponse struct {
|
|
MediaFileID int `json:"media_file_id"`
|
|
MediaFolderID int `json:"media_folder_id"`
|
|
FilePath string `json:"file_path"`
|
|
BaseTitle string `json:"base_title,omitempty"`
|
|
BaseYear int `json:"base_year,omitempty"`
|
|
BaseType string `json:"base_type,omitempty"`
|
|
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r *http.Request) {
|
|
if h.folderRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library repository is not configured")
|
|
return
|
|
}
|
|
|
|
folders, err := h.folderRepo.List(r.Context())
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to list libraries", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues")
|
|
return
|
|
}
|
|
|
|
resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folders))
|
|
for _, folder := range folders {
|
|
if folder == nil {
|
|
continue
|
|
}
|
|
status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to load queue status", "library_id", folder.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
|
return
|
|
}
|
|
resp = append(resp, status)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
|
if !h.metadataMatchBacklogConfigured() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
limit := 10
|
|
if value := strings.TrimSpace(r.URL.Query().Get("limit")); value != "" {
|
|
if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 50 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to load queue status", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
|
return
|
|
}
|
|
|
|
resp := libraryMetadataMatchQueueDetailResponse{
|
|
libraryMetadataMatchQueueStatusResponse: status,
|
|
Movies: []libraryMovieMatchQueueEntryResponse{},
|
|
Series: []librarySeriesMatchQueueEntryResponse{},
|
|
RawFiles: []libraryRawMatchBacklogEntryResponse{},
|
|
}
|
|
if h.MovieMatchQueueRepo != nil {
|
|
movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to list movie queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
|
return
|
|
}
|
|
for _, entry := range movies {
|
|
resp.Movies = append(resp.Movies, libraryMovieMatchQueueEntryResponse{
|
|
MediaFileID: entry.MediaFileID,
|
|
MediaFolderID: entry.MediaFolderID,
|
|
FilePath: entry.FilePath,
|
|
FirstQueuedAt: entry.FirstQueuedAt,
|
|
AvailableAt: entry.AvailableAt,
|
|
LastAttemptedAt: entry.LastAttemptedAt,
|
|
AttemptCount: entry.AttemptCount,
|
|
LastError: entry.LastError,
|
|
UpdatedAt: entry.UpdatedAt,
|
|
})
|
|
}
|
|
}
|
|
if h.SeriesMatchQueueRepo != nil {
|
|
series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to list series queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
|
|
return
|
|
}
|
|
for _, entry := range series {
|
|
resp.Series = append(resp.Series, librarySeriesMatchQueueEntryResponse{
|
|
MediaFolderID: entry.MediaFolderID,
|
|
ObservedRootPath: entry.ObservedRootPath,
|
|
FirstQueuedAt: entry.FirstQueuedAt,
|
|
AvailableAt: entry.AvailableAt,
|
|
LastAttemptedAt: entry.LastAttemptedAt,
|
|
AttemptCount: entry.AttemptCount,
|
|
LastError: entry.LastError,
|
|
UpdatedAt: entry.UpdatedAt,
|
|
})
|
|
}
|
|
}
|
|
if h.RawMatchBacklogRepo != nil {
|
|
rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to list raw backlog", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog")
|
|
return
|
|
}
|
|
for _, file := range rawFiles {
|
|
if file == nil {
|
|
continue
|
|
}
|
|
resp.RawFiles = append(resp.RawFiles, libraryRawMatchBacklogEntryResponse{
|
|
MediaFileID: file.ID,
|
|
MediaFolderID: file.MediaFolderID,
|
|
FilePath: file.FilePath,
|
|
BaseTitle: file.BaseTitle,
|
|
BaseYear: file.BaseYear,
|
|
BaseType: file.BaseType,
|
|
LastAttemptedAt: file.MatchAttemptedAt,
|
|
CreatedAt: file.CreatedAt,
|
|
UpdatedAt: file.UpdatedAt,
|
|
})
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
|
if !h.metadataMatchBacklogConfigured() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
if h.SeriesMatchQueueRepo != nil {
|
|
if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
|
slog.Error("metadata queue: failed to retry series queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
if h.MovieMatchQueueRepo != nil {
|
|
if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
|
|
slog.Error("metadata queue: failed to retry movie queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
rawFileRetried := 0
|
|
if h.RawMatchBacklogRepo != nil {
|
|
rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to retry raw backlog", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
|
|
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to load retried queue status", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{
|
|
Status: "queued",
|
|
LibraryID: id,
|
|
RawFileRetried: rawFileRetried,
|
|
Queue: status,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r *http.Request) {
|
|
if !h.metadataMatchBacklogConfigured() {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
seriesCancelled := 0
|
|
if h.SeriesMatchQueueRepo != nil {
|
|
seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to cancel series queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
movieCancelled := 0
|
|
if h.MovieMatchQueueRepo != nil {
|
|
movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to cancel movie queue", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
rawFileCancelled := 0
|
|
if h.RawMatchBacklogRepo != nil {
|
|
rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode())
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to suppress raw backlog", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher")
|
|
return
|
|
}
|
|
}
|
|
|
|
status, err := h.metadataMatchQueueStatus(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("metadata queue: failed to load cancelled queue status", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{
|
|
Status: "cancelled",
|
|
LibraryID: id,
|
|
MovieCancelled: movieCancelled,
|
|
SeriesCancelled: seriesCancelled,
|
|
RawFileCancelled: rawFileCancelled,
|
|
TotalCancelled: movieCancelled + seriesCancelled + rawFileCancelled,
|
|
Queue: status,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryHandler) metadataMatchBacklogConfigured() bool {
|
|
return h.folderRepo != nil &&
|
|
(h.MovieMatchQueueRepo != nil || h.SeriesMatchQueueRepo != nil || h.RawMatchBacklogRepo != nil)
|
|
}
|
|
|
|
func (h *LibraryHandler) metadataMatchQueueStatus(ctx context.Context, libraryID int) (libraryMetadataMatchQueueStatusResponse, error) {
|
|
resp := libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}
|
|
if h.MovieMatchQueueRepo != nil {
|
|
count, err := h.MovieMatchQueueRepo.CountByFolder(ctx, libraryID)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
resp.MovieCount = count
|
|
}
|
|
if h.SeriesMatchQueueRepo != nil {
|
|
count, err := h.SeriesMatchQueueRepo.CountByFolder(ctx, libraryID)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
resp.SeriesCount = count
|
|
}
|
|
if h.RawMatchBacklogRepo != nil {
|
|
count, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolder(ctx, libraryID, h.rawMatchBacklogMode())
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
resp.RawFileCount = count
|
|
}
|
|
resp.TotalCount = resp.MovieCount + resp.SeriesCount + resp.RawFileCount
|
|
return resp, nil
|
|
}
|
|
|
|
func (h *LibraryHandler) rawMatchBacklogMode() scanner.RawMatchBacklogMode {
|
|
if h.TVSeriesRootQueue && h.MovieMatchQueueRepo != nil {
|
|
return scanner.RawMatchBacklogMixed
|
|
}
|
|
if h.TVSeriesRootQueue {
|
|
return scanner.RawMatchBacklogNonSeries
|
|
}
|
|
return scanner.RawMatchBacklogGeneric
|
|
}
|
|
|
|
// HandleRefreshLibraryMetadata handles POST /libraries/{id}/refresh-metadata.
|
|
// It queues a background admin job to refresh metadata for items in the
|
|
// specified library. Quick mode is the default.
|
|
func (h *LibraryHandler) HandleRefreshLibraryMetadata(w http.ResponseWriter, r *http.Request) {
|
|
if h.JobRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Library refresh jobs are not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
mode := adminjob.LibraryRefreshModeQuick
|
|
if r.Body != nil {
|
|
var req refreshLibraryMetadataRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.Mode != "" {
|
|
switch adminjob.LibraryRefreshMode(req.Mode) {
|
|
case adminjob.LibraryRefreshModeQuick, adminjob.LibraryRefreshModeFull:
|
|
mode = adminjob.LibraryRefreshMode(req.Mode)
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid refresh mode")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
job, err := h.JobRepo.CreateLibraryRefresh(r.Context(), currentAdminUserID(r), adminjob.LibraryRefreshRequest{
|
|
LibraryID: folder.ID,
|
|
LibraryName: folder.Name,
|
|
Mode: mode,
|
|
}, fmt.Sprintf("Queued %s library metadata refresh", mode))
|
|
if err != nil {
|
|
var conflict *adminjob.ActiveJobConflictError
|
|
if errors.As(err, &conflict) {
|
|
jobsHandler := NewAdminJobsHandler(nil, nil)
|
|
writeAdminJobConflict(w, "A metadata refresh is already queued or running for this library", conflict.Job, jobsHandler, r)
|
|
return
|
|
}
|
|
slog.Error("library: queue library refresh failed", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library metadata refresh")
|
|
return
|
|
}
|
|
publishEventJob(r.Context(), h.EventsHub, "job.created", job)
|
|
|
|
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil))
|
|
}
|
|
|
|
// HandleConfirmEmptyRootCleanup handles POST /libraries/{id}/confirm-empty-root-cleanup.
|
|
// It arms the next empty-root scan for destructive cleanup.
|
|
func (h *LibraryHandler) HandleConfirmEmptyRootCleanup(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
if err := h.folderRepo.AllowEmptyCleanupOnce(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to confirm cleanup")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "ok",
|
|
"message": "Empty-root cleanup confirmed for next scan",
|
|
})
|
|
}
|
|
|
|
// --- Library poster handlers ---
|
|
|
|
// HandleUploadPoster handles PUT /libraries/{id}/poster.
|
|
// Accepts a multipart form upload with a single "poster" file field.
|
|
func (h *LibraryHandler) HandleUploadPoster(w http.ResponseWriter, r *http.Request) {
|
|
if h.S3Meta == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
// Parse multipart form (max 10 MB).
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("poster")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Missing poster file")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate content type.
|
|
ct := header.Header.Get("Content-Type")
|
|
ext := posterExtension(ct)
|
|
if ext == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use JPEG, PNG, or WebP")
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(file, 10<<20+1))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
|
return
|
|
}
|
|
if len(data) > 10<<20 {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Poster must be under 10 MB")
|
|
return
|
|
}
|
|
|
|
// Delete old poster if it exists and has a different key.
|
|
s3Key := fmt.Sprintf("library-posters/%d%s", id, ext)
|
|
if folder.PosterPath != "" && folder.PosterPath != s3Key {
|
|
_ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath)
|
|
}
|
|
|
|
if err := h.S3Meta.PutObject(r.Context(), h.S3Meta.Bucket(), s3Key, data); err != nil {
|
|
slog.Error("uploading library poster", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to upload poster")
|
|
return
|
|
}
|
|
|
|
if err := h.folderRepo.SetPosterPath(r.Context(), id, s3Key); err != nil {
|
|
slog.Error("saving library poster path", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save poster")
|
|
return
|
|
}
|
|
|
|
folder.PosterPath = s3Key
|
|
writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder))
|
|
}
|
|
|
|
// HandleDeletePoster handles DELETE /libraries/{id}/poster.
|
|
func (h *LibraryHandler) HandleDeletePoster(w http.ResponseWriter, r *http.Request) {
|
|
if h.S3Meta == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
if folder.PosterPath != "" {
|
|
_ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath)
|
|
if err := h.folderRepo.ClearPosterPath(r.Context(), id); err != nil {
|
|
slog.Error("clearing library poster path", "library_id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster")
|
|
return
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// posterExtension returns the file extension for a valid poster content type.
|
|
func posterExtension(contentType string) string {
|
|
switch contentType {
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/webp":
|
|
return ".webp"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// --- Provider chain types ---
|
|
|
|
// chainLevelEntry represents a single entry in a per-level provider chain response.
|
|
type chainLevelEntry struct {
|
|
PluginInstallationID int `json:"plugin_installation_id"`
|
|
CapabilityID string `json:"capability_id"`
|
|
ProviderSlug string `json:"provider_slug"`
|
|
Priority int `json:"priority"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// setChainLevelRequest is the JSON body for PUT /libraries/{id}/providers.
|
|
type setChainLevelRequest struct {
|
|
Levels map[string][]chainEntryInput `json:"levels"`
|
|
}
|
|
|
|
// chainEntryInput is a single entry in a set-chain request.
|
|
type chainEntryInput struct {
|
|
PluginInstallationID int `json:"plugin_installation_id"`
|
|
CapabilityID string `json:"capability_id"`
|
|
Priority int `json:"priority"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// HandleGetLibraryProviders handles GET /libraries/{id}/providers.
|
|
// It returns the provider chain for the given library grouped by content level.
|
|
func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *http.Request) {
|
|
if h.ChainRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
// Verify the library exists.
|
|
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("fetching library for provider chain", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
entries, err := h.ChainRepo.GetAllChainEntries(r.Context(), id)
|
|
if err != nil {
|
|
slog.Error("getting provider chain", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get provider chain")
|
|
return
|
|
}
|
|
|
|
// Group by content level — capability_id is the provider slug.
|
|
levels := make(map[string][]chainLevelEntry)
|
|
for _, e := range entries {
|
|
levels[e.ContentLevel] = append(levels[e.ContentLevel], chainLevelEntry{
|
|
PluginInstallationID: e.PluginInstallationID,
|
|
CapabilityID: e.CapabilityID,
|
|
ProviderSlug: e.CapabilityID,
|
|
Priority: e.Priority,
|
|
Enabled: e.Enabled,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"levels": levels})
|
|
}
|
|
|
|
// HandleSetLibraryProviders handles PUT /libraries/{id}/providers.
|
|
// It replaces the entire provider chain for the given library.
|
|
func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *http.Request) {
|
|
if h.ChainRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured")
|
|
return
|
|
}
|
|
|
|
id, err := parseIDParam(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return
|
|
}
|
|
|
|
// Verify the library exists.
|
|
if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, catalog.ErrFolderNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
slog.Error("fetching library for provider chain update", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library")
|
|
return
|
|
}
|
|
|
|
var req setChainLevelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
var entries []metadata.ChainEntry
|
|
for level, inputs := range req.Levels {
|
|
for _, input := range inputs {
|
|
entries = append(entries, metadata.ChainEntry{
|
|
PluginInstallationID: input.PluginInstallationID,
|
|
CapabilityID: input.CapabilityID,
|
|
CapabilityType: "metadata_provider.v1",
|
|
ContentLevel: level,
|
|
Priority: input.Priority,
|
|
Enabled: input.Enabled,
|
|
})
|
|
}
|
|
}
|
|
|
|
if err := h.ChainRepo.SetChain(r.Context(), id, entries); err != nil {
|
|
slog.Error("setting provider chain", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set provider chain")
|
|
return
|
|
}
|
|
if h.chainCacheInvalidator != nil {
|
|
h.chainCacheInvalidator.InvalidateChainCache()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// seedDefaultChain builds a default provider chain from plugin manifest defaults
|
|
// for the given library type. Returns entries for all applicable content levels.
|
|
func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType string) []metadata.ChainEntry {
|
|
if h.ChainRepo == nil {
|
|
return nil
|
|
}
|
|
|
|
caps, err := metadata.ListEnabledMetadataCapabilities(ctx, h.ChainRepo.Pool())
|
|
if err != nil {
|
|
slog.Warn("seed chain: failed to list metadata capabilities", "error", err)
|
|
return nil
|
|
}
|
|
|
|
// Determine which content levels apply.
|
|
var levels []string
|
|
switch libraryType {
|
|
case "series":
|
|
levels = []string{"series", "season", "episode"}
|
|
case "movies", "movie":
|
|
levels = []string{"movie"}
|
|
case "mixed":
|
|
levels = []string{"movie", "series", "season", "episode"}
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
var entries []metadata.ChainEntry
|
|
for _, level := range levels {
|
|
type candidate struct {
|
|
installationID int
|
|
capabilityID string
|
|
priority int
|
|
enabled bool
|
|
}
|
|
var candidates []candidate
|
|
|
|
for _, c := range caps {
|
|
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
|
|
if defaultPriority > 0 {
|
|
candidates = append(candidates, candidate{
|
|
installationID: c.PluginInstallationID,
|
|
capabilityID: c.CapabilityID,
|
|
priority: defaultPriority,
|
|
enabled: true,
|
|
})
|
|
} else {
|
|
// Plugin doesn't declare this level — include but disabled.
|
|
candidates = append(candidates, candidate{
|
|
installationID: c.PluginInstallationID,
|
|
capabilityID: c.CapabilityID,
|
|
priority: 999,
|
|
enabled: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].priority < candidates[j].priority
|
|
})
|
|
|
|
for i, cand := range candidates {
|
|
entries = append(entries, metadata.ChainEntry{
|
|
PluginInstallationID: cand.installationID,
|
|
CapabilityID: cand.capabilityID,
|
|
CapabilityType: "metadata_provider.v1",
|
|
ContentLevel: level,
|
|
Priority: i,
|
|
Enabled: cand.enabled,
|
|
})
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// HandleListStaleIDs handles GET /libraries/stale-ids.
|
|
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
|
|
if h.StaleIDRepo == nil {
|
|
writeJSON(w, http.StatusOK, []staleMediaIDResponse{})
|
|
return
|
|
}
|
|
|
|
staleIDs, err := h.StaleIDRepo.ListAll(r.Context())
|
|
if err != nil {
|
|
slog.Error("listing stale media IDs", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list stale IDs")
|
|
return
|
|
}
|
|
if len(staleIDs) == 0 {
|
|
writeJSON(w, http.StatusOK, []staleMediaIDResponse{})
|
|
return
|
|
}
|
|
|
|
// Collect unique content IDs for batch lookup.
|
|
contentIDs := make([]string, 0, len(staleIDs))
|
|
seen := make(map[string]bool, len(staleIDs))
|
|
for _, s := range staleIDs {
|
|
if !seen[s.ContentID] {
|
|
contentIDs = append(contentIDs, s.ContentID)
|
|
seen[s.ContentID] = true
|
|
}
|
|
}
|
|
|
|
// Batch-load item metadata and library associations.
|
|
type itemInfo struct {
|
|
Title string
|
|
Year int
|
|
ContentType string
|
|
LibraryID int
|
|
LibraryName string
|
|
}
|
|
items := make(map[string]itemInfo, len(contentIDs))
|
|
|
|
rows, err := h.pool.Query(r.Context(), `
|
|
SELECT mi.content_id, mi.title, mi.year, mi.type,
|
|
COALESCE(mf_lib.folder_id, 0),
|
|
COALESCE(mf_lib.folder_name, '')
|
|
FROM media_items mi
|
|
LEFT JOIN LATERAL (
|
|
SELECT mf2.media_folder_id AS folder_id, f.name AS folder_name
|
|
FROM media_files mf2
|
|
JOIN media_folders f ON f.id = mf2.media_folder_id
|
|
WHERE mf2.content_id = mi.content_id
|
|
LIMIT 1
|
|
) mf_lib ON true
|
|
WHERE mi.content_id = ANY($1)
|
|
`, contentIDs)
|
|
if err != nil {
|
|
slog.Error("loading items for stale IDs", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item data")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var cid, title, ctype, libName string
|
|
var year, libID int
|
|
if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil {
|
|
slog.Error("scanning item for stale IDs", "error", err)
|
|
continue
|
|
}
|
|
items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName}
|
|
}
|
|
|
|
resp := make([]staleMediaIDResponse, 0, len(staleIDs))
|
|
for _, s := range staleIDs {
|
|
info := items[s.ContentID]
|
|
resp = append(resp, staleMediaIDResponse{
|
|
ContentID: s.ContentID,
|
|
LibraryID: info.LibraryID,
|
|
LibraryName: info.LibraryName,
|
|
Title: info.Title,
|
|
Year: info.Year,
|
|
ContentType: info.ContentType,
|
|
Provider: s.Provider,
|
|
ProviderID: s.ProviderID,
|
|
FirstSeenAt: s.FirstSeenAt.Format("2006-01-02T15:04:05Z"),
|
|
LastSeenAt: s.LastSeenAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleRematchStaleID handles POST /libraries/stale-ids/{contentID}/rematch.
|
|
// Deprecated: prefer the explicit admin match search/apply flow via
|
|
// POST /admin/items/{id}/match/search and POST /admin/items/{id}/match/apply.
|
|
func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Request) {
|
|
contentID := chi.URLParam(r, "contentID")
|
|
if contentID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Missing content ID")
|
|
return
|
|
}
|
|
|
|
// Clear the stale external IDs from the media_items row.
|
|
// We clear all provider IDs so the re-match starts fresh from title/year.
|
|
_, err := h.pool.Exec(r.Context(), `
|
|
UPDATE media_items
|
|
SET tmdb_id = '', tvdb_id = '', imdb_id = ''
|
|
WHERE content_id = $1
|
|
`, contentID)
|
|
if err != nil {
|
|
slog.Error("clearing stale IDs from media item", "content_id", contentID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear IDs")
|
|
return
|
|
}
|
|
|
|
// Remove stale_media_ids records.
|
|
if h.StaleIDRepo != nil {
|
|
if err := h.StaleIDRepo.DeleteByContentID(r.Context(), contentID); err != nil {
|
|
slog.Error("deleting stale media ID records", "content_id", contentID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Re-trigger metadata match.
|
|
if h.refresher != nil {
|
|
go func() {
|
|
if err := h.refresher.RefreshItem(h.appCtx, contentID); err != nil {
|
|
slog.Warn("metadata: rematch refresh failed", "content_id", contentID, "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) {
|
|
if h.ScannedGroupRepo == nil || h.folderRepo == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Group snapshots not configured")
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
libraryID, err := strconv.Atoi(q.Get("library_id"))
|
|
if err != nil || libraryID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required")
|
|
return
|
|
}
|
|
limit := 100
|
|
if value := strings.TrimSpace(q.Get("limit")); value != "" {
|
|
if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 500 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
offset := 0
|
|
if value := strings.TrimSpace(q.Get("offset")); value != "" {
|
|
if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed >= 0 {
|
|
offset = parsed
|
|
}
|
|
}
|
|
state := strings.TrimSpace(q.Get("state"))
|
|
|
|
folder, err := h.folderRepo.GetByID(r.Context(), libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
|
|
groups, total, err := h.ScannedGroupRepo.ListByFolder(r.Context(), libraryID, state, limit, offset)
|
|
if err != nil {
|
|
slog.Error("listing scanned groups", "library_id", libraryID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list roots")
|
|
return
|
|
}
|
|
|
|
overrideByGroup := map[string]models.MediaGroupOverride{}
|
|
if h.GroupOverrideRepo != nil {
|
|
overrides, err := h.GroupOverrideRepo.ListByFolder(r.Context(), libraryID)
|
|
if err != nil {
|
|
slog.Warn("listing group overrides", "library_id", libraryID, "error", err)
|
|
} else {
|
|
for _, override := range overrides {
|
|
overrideByGroup[groupOverrideLookupKey(override.GroupKeyVersion, override.ContentGroupKey)] = override
|
|
}
|
|
}
|
|
}
|
|
|
|
items := make([]libraryRootResponse, 0, len(groups))
|
|
for _, group := range groups {
|
|
rootPath := strings.TrimSpace(group.SampleObservedRootPath)
|
|
if rootPath == "" {
|
|
rootPath = filepath.Dir(group.SampleFilePath)
|
|
}
|
|
resp := libraryRootResponse{
|
|
LibraryID: libraryID,
|
|
LibraryName: folder.Name,
|
|
RootPath: rootPath,
|
|
State: group.State,
|
|
InferredType: group.InferredType,
|
|
TypeConfidence: group.TypeConfidence,
|
|
Title: group.BaseTitle,
|
|
Year: group.BaseYear,
|
|
TmdbID: group.TmdbID,
|
|
ImdbID: group.ImdbID,
|
|
TvdbID: group.TvdbID,
|
|
ObservedFiles: group.ObservedFileCount,
|
|
SampleFilePath: group.SampleFilePath,
|
|
Evidence: append(json.RawMessage(nil), group.EvidenceJSON...),
|
|
OverrideSource: group.OverrideSource,
|
|
FirstSeenAt: group.FirstSeenAt,
|
|
LastSeenAt: group.LastSeenAt,
|
|
}
|
|
if override, ok := overrideByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)]; ok {
|
|
resp.ActiveOverride = &rootOverride{
|
|
ForcedType: override.ForcedType,
|
|
ForcedTitle: override.ForcedTitle,
|
|
ForcedYear: override.ForcedYear,
|
|
ForcedTmdbID: override.ForcedTmdbID,
|
|
ForcedImdbID: override.ForcedImdbID,
|
|
ForcedTvdbID: override.ForcedTvdbID,
|
|
Note: override.Note,
|
|
}
|
|
}
|
|
items = append(items, resp)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, libraryRootsListResponse{Items: items, Total: total})
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http.Request) {
|
|
if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured")
|
|
return
|
|
}
|
|
|
|
var req rootOverrideUpsertRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath))
|
|
if req.LibraryID <= 0 || req.RootPath == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required")
|
|
return
|
|
}
|
|
location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath)
|
|
if err != nil {
|
|
slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root")
|
|
return
|
|
}
|
|
if location == nil || location.PrimaryContentGroupKey == "" {
|
|
if location != nil && location.ContentGroupCount > 1 {
|
|
writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; override the group after splitting or selecting a specific item")
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "Root not found")
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
override := models.MediaGroupOverride{
|
|
MediaFolderID: req.LibraryID,
|
|
GroupKeyVersion: location.PrimaryGroupKeyVersion,
|
|
ContentGroupKey: location.PrimaryContentGroupKey,
|
|
ForcedType: strings.TrimSpace(req.ForcedType),
|
|
ForcedTitle: strings.TrimSpace(req.ForcedTitle),
|
|
ForcedYear: req.ForcedYear,
|
|
ForcedTmdbID: strings.TrimSpace(req.ForcedTmdbID),
|
|
ForcedImdbID: strings.TrimSpace(req.ForcedImdbID),
|
|
ForcedTvdbID: strings.TrimSpace(req.ForcedTvdbID),
|
|
Note: strings.TrimSpace(req.Note),
|
|
CreatedByUserID: nil,
|
|
UpdatedByUserID: nil,
|
|
}
|
|
if userID > 0 {
|
|
override.CreatedByUserID = &userID
|
|
override.UpdatedByUserID = &userID
|
|
}
|
|
if err := h.GroupOverrideRepo.Upsert(r.Context(), override); err != nil {
|
|
slog.Error("upserting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save override")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http.Request) {
|
|
if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured")
|
|
return
|
|
}
|
|
|
|
var req rootOverrideDeleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath))
|
|
if req.LibraryID <= 0 || req.RootPath == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required")
|
|
return
|
|
}
|
|
location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath)
|
|
if err != nil {
|
|
slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root")
|
|
return
|
|
}
|
|
if location == nil || location.PrimaryContentGroupKey == "" {
|
|
if location != nil && location.ContentGroupCount > 1 {
|
|
writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; delete the override from a specific group instead")
|
|
return
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "Root not found")
|
|
return
|
|
}
|
|
|
|
if err := h.GroupOverrideRepo.Delete(r.Context(), req.LibraryID, location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey); err != nil {
|
|
slog.Error("deleting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete override")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// unmatchedItemResponse represents an item in the unmatched-items list.
|
|
type unmatchedItemResponse struct {
|
|
ContentID string `json:"content_id"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
ContentType string `json:"content_type"`
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type unmatchedItemsListResponse struct {
|
|
Items []unmatchedItemResponse `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// HandleListUnmatchedItems handles GET /libraries/unmatched-items.
|
|
// Returns items that are in unmatched, pending, or ambiguous status, enriched with
|
|
// library context so the admin maintenance page can link to them.
|
|
func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http.Request) {
|
|
if h.pool == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured")
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
limit := 100
|
|
if v := q.Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
}
|
|
offset := 0
|
|
if v := q.Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
offset = n
|
|
}
|
|
}
|
|
|
|
// Optional case-insensitive search across title, library name, type, and
|
|
// status. Applied server-side so it spans the whole table, not just the
|
|
// current page. The displayed folder still comes from the lateral join below,
|
|
// but the search predicate checks every membership so multi-library items are
|
|
// found when any linked library name matches.
|
|
search := strings.TrimSpace(q.Get("q"))
|
|
filter := ""
|
|
filterArgs := []any{}
|
|
if search != "" {
|
|
filterArgs = append(filterArgs, "%"+search+"%")
|
|
filter = ` AND (
|
|
mi.title ILIKE $1
|
|
OR mi.type ILIKE $1
|
|
OR mi.status ILIKE $1
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM media_item_libraries search_mil
|
|
JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id
|
|
WHERE search_mil.content_id = mi.content_id
|
|
AND search_f.name ILIKE $1
|
|
)
|
|
)`
|
|
}
|
|
|
|
countSQL := `
|
|
SELECT COUNT(*)
|
|
FROM media_items mi
|
|
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')`
|
|
countSQL += filter
|
|
|
|
var total int
|
|
if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil {
|
|
slog.Error("counting unmatched items", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items")
|
|
return
|
|
}
|
|
|
|
listArgs := append(append([]any{}, filterArgs...), limit, offset)
|
|
listSQL := fmt.Sprintf(`
|
|
SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status,
|
|
COALESCE(lib.folder_id, 0),
|
|
COALESCE(lib.folder_name, '')
|
|
FROM media_items mi
|
|
LEFT JOIN LATERAL (
|
|
SELECT mil.media_folder_id AS folder_id, f.name AS folder_name
|
|
FROM media_item_libraries mil
|
|
JOIN media_folders f ON f.id = mil.media_folder_id
|
|
WHERE mil.content_id = mi.content_id
|
|
LIMIT 1
|
|
) lib ON true
|
|
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s
|
|
ORDER BY mi.title ASC, mi.content_id ASC
|
|
LIMIT $%d OFFSET $%d
|
|
`, filter, len(filterArgs)+1, len(filterArgs)+2)
|
|
|
|
rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
|
|
if err != nil {
|
|
slog.Error("listing unmatched items", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]unmatchedItemResponse, 0)
|
|
for rows.Next() {
|
|
var item unmatchedItemResponse
|
|
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &item.ContentType, &item.Status, &item.LibraryID, &item.LibraryName); err != nil {
|
|
slog.Error("scanning unmatched item", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item")
|
|
return
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Error("iterating unmatched items", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate items")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, unmatchedItemsListResponse{
|
|
Items: items,
|
|
Total: total,
|
|
})
|
|
}
|
|
|
|
// parseIDParam extracts and parses the "id" URL parameter as an integer.
|
|
func parseIDParam(r *http.Request) (int, error) {
|
|
idStr := chi.URLParam(r, "id")
|
|
return strconv.Atoi(idStr)
|
|
}
|