feat(realtime): improve web UI reactivity and admin visibility (#48)
* 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>
This commit is contained in:
committed by
GitHub
co-authored by
Quick
fluxis
Claude Opus 4.8
parent
177cfdc485
commit
9f73ac6f1a
@@ -485,6 +485,8 @@ func main() {
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
}
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
if needsWorkers && deps.DB != nil {
|
||||
deps.IntroRepository = intromarkers.NewRepository(deps.DB)
|
||||
deps.IntroAnalyzer = intromarkers.NewAnalyzer(
|
||||
@@ -870,6 +872,8 @@ func main() {
|
||||
providerIDRepo := catalog.NewProviderIDRepository(deps.DB)
|
||||
movieQueueRepo = metadata.NewMovieMatchQueueRepository(deps.DB, deps.FileRepo)
|
||||
seriesQueueRepo = metadata.NewSeriesRootMatchQueueRepository(deps.DB)
|
||||
deps.MovieMatchQueueRepo = movieQueueRepo
|
||||
deps.SeriesRootMatchQueueRepo = seriesQueueRepo
|
||||
matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo)
|
||||
rootClaimRepo = catalog.NewRootClaimRepository(deps.DB)
|
||||
groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB)
|
||||
@@ -900,6 +904,7 @@ func main() {
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
}
|
||||
@@ -1629,6 +1634,7 @@ func main() {
|
||||
templateBundleApplyExecutor,
|
||||
deps.RealtimeHub,
|
||||
)
|
||||
adminJobRunner.SetCancelRegistry(adminJobCancelRegistry)
|
||||
adminJobRunner.Start()
|
||||
defer adminJobRunner.Stop()
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
RequestedMediaFileID: 41,
|
||||
PlayMethod: playback.PlayTranscode,
|
||||
BasePlayMethod: playback.PlayRemux,
|
||||
TranscodeHWAccel: "qsv",
|
||||
Position: 125.5,
|
||||
IsPaused: true,
|
||||
},
|
||||
want: "transcode",
|
||||
},
|
||||
@@ -164,6 +167,18 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
if got.SessionID != tc.session.ID {
|
||||
t.Fatalf("SessionID = %q, want %q", got.SessionID, tc.session.ID)
|
||||
}
|
||||
if got.ProfileID != tc.session.ProfileID {
|
||||
t.Fatalf("ProfileID = %q, want %q", got.ProfileID, tc.session.ProfileID)
|
||||
}
|
||||
if got.PositionSeconds != tc.session.Position {
|
||||
t.Fatalf("PositionSeconds = %v, want %v", got.PositionSeconds, tc.session.Position)
|
||||
}
|
||||
if got.IsPaused != tc.session.IsPaused {
|
||||
t.Fatalf("IsPaused = %v, want %v", got.IsPaused, tc.session.IsPaused)
|
||||
}
|
||||
if got.TranscodeHWAccel != tc.session.TranscodeHWAccel {
|
||||
t.Fatalf("TranscodeHWAccel = %q, want %q", got.TranscodeHWAccel, tc.session.TranscodeHWAccel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
|
||||
TargetVideoCodec: s.TargetVideoCodec,
|
||||
TargetAudioCodec: s.TargetAudioCodec,
|
||||
TargetBitrateKbps: s.TargetBitrateKbps,
|
||||
TranscodeHWAccel: s.TranscodeHWAccel,
|
||||
StartedAt: s.StartedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
PositionSeconds: s.Position,
|
||||
IsPaused: s.IsPaused,
|
||||
HasWebSocket: s.HasWebSocket,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package adminjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CancelRegistry bridges HTTP cancellation requests to in-process job contexts.
|
||||
// Queued jobs are cancelled in the repository; running jobs need their active
|
||||
// context cancelled so executors stop doing work before the row is finalized.
|
||||
type CancelRegistry struct {
|
||||
mu sync.Mutex
|
||||
cancels map[string]cancelEntry
|
||||
}
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
token *struct{}
|
||||
}
|
||||
|
||||
func NewCancelRegistry() *CancelRegistry {
|
||||
return &CancelRegistry{cancels: make(map[string]cancelEntry)}
|
||||
}
|
||||
|
||||
func (r *CancelRegistry) Register(jobID string, cancel context.CancelFunc) func() {
|
||||
if r == nil || jobID == "" || cancel == nil {
|
||||
return func() {}
|
||||
}
|
||||
token := &struct{}{}
|
||||
r.mu.Lock()
|
||||
r.cancels[jobID] = cancelEntry{cancel: cancel, token: token}
|
||||
r.mu.Unlock()
|
||||
|
||||
return func() {
|
||||
r.mu.Lock()
|
||||
if entry := r.cancels[jobID]; entry.token == token {
|
||||
delete(r.cancels, jobID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CancelRegistry) Cancel(jobID string) bool {
|
||||
if r == nil || jobID == "" {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[jobID].cancel
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
@@ -542,17 +542,19 @@ func (e *ItemRefreshExecutor) Execute(ctx context.Context, req ItemRefreshReques
|
||||
e.publish(cache.EventMetadataUpdated, refreshContentID)
|
||||
if e.realtimeHub != nil {
|
||||
if scanResult != nil && (scanResult.New > 0 || scanResult.Updated > 0 || scanResult.Missing > 0 || matched > 0) {
|
||||
_ = e.realtimeHub.PublishLibraryChanged(ctx, notifications.LibraryChangeEvent{
|
||||
_ = e.realtimeHub.PublishCatalogLibraryChanged(ctx, notifications.LibraryChangeEvent{
|
||||
LibraryID: req.ScanFolderID,
|
||||
Reason: "item_refresh",
|
||||
New: scanResult.New,
|
||||
Updated: scanResult.Updated,
|
||||
Missing: scanResult.Missing,
|
||||
MatchedFiles: matched,
|
||||
})
|
||||
}
|
||||
_ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: req.ScanFolderID,
|
||||
ContentID: refreshContentID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -251,8 +251,9 @@ func (e *LibraryRefreshExecutor) Execute(
|
||||
if result.RefreshedOK > 0 || result.PipelineOK > 0 {
|
||||
e.publish(cache.EventMetadataUpdated, strconv.Itoa(req.LibraryID))
|
||||
if e.realtimeHub != nil {
|
||||
_ = e.realtimeHub.PublishMetadataUpdated(ctx, notifications.MetadataUpdateEvent{
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: req.LibraryID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -321,6 +322,7 @@ func (e *LibraryRefreshExecutor) refreshItemsWithIDs(
|
||||
result.RefreshedFailed++
|
||||
} else {
|
||||
result.RefreshedOK++
|
||||
e.publishCatalogItemChanged(ctx, result.LibraryID, itemResult.contentID)
|
||||
}
|
||||
advance("Refreshing items with external IDs")
|
||||
}
|
||||
@@ -386,6 +388,7 @@ func (e *LibraryRefreshExecutor) refreshUnmatchedItem(ctx context.Context, libra
|
||||
if err := e.refresher.RefreshItemForLibrary(ctx, req.RefreshContentID, req.ScanFolderID); err != nil {
|
||||
return fmt.Errorf("refresh metadata: %w", err)
|
||||
}
|
||||
e.publishCatalogItemChanged(ctx, libraryID, req.RefreshContentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -399,6 +402,17 @@ func (e *LibraryRefreshExecutor) publish(eventType, payload string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (e *LibraryRefreshExecutor) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string) {
|
||||
if e == nil || e.realtimeHub == nil || contentID == "" {
|
||||
return
|
||||
}
|
||||
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: libraryID,
|
||||
ContentID: contentID,
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
}
|
||||
|
||||
func decodeLibraryRefreshRequest(data json.RawMessage) (LibraryRefreshRequest, error) {
|
||||
var req LibraryRefreshRequest
|
||||
if len(data) == 0 {
|
||||
|
||||
@@ -24,11 +24,13 @@ const (
|
||||
StatusRunning = "running"
|
||||
StatusCompleted = "completed"
|
||||
StatusFailed = "failed"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrJobNotFound = errors.New("admin job not found")
|
||||
ErrActiveJobConflict = errors.New("admin job already active for type")
|
||||
ErrJobNotCancellable = errors.New("admin job is not cancellable")
|
||||
)
|
||||
|
||||
type ActiveJobConflictError struct {
|
||||
@@ -471,6 +473,83 @@ func (r *Repository) Fail(ctx context.Context, id string, input FailJobInput) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) {
|
||||
if message == "" {
|
||||
message = "Admin job cancelled"
|
||||
}
|
||||
job, err := scanAdminJob(r.pool.QueryRow(ctx, `
|
||||
UPDATE admin_jobs
|
||||
SET status = $2,
|
||||
message = $3,
|
||||
error_message = '',
|
||||
completed_at = NOW(),
|
||||
heartbeat_at = NOW(),
|
||||
expires_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status IN ($5, $6)
|
||||
RETURNING `+adminJobColumns,
|
||||
id,
|
||||
StatusCancelled,
|
||||
message,
|
||||
expiresAt,
|
||||
StatusQueued,
|
||||
StatusRunning,
|
||||
))
|
||||
if err == nil {
|
||||
return job, nil
|
||||
}
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
existing, lookupErr := r.GetByID(ctx, id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if existing.Status != StatusQueued && existing.Status != StatusRunning {
|
||||
return nil, ErrJobNotCancellable
|
||||
}
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error) {
|
||||
if message == "" {
|
||||
message = "Admin job cancelled"
|
||||
}
|
||||
job, err := scanAdminJob(r.pool.QueryRow(ctx, `
|
||||
UPDATE admin_jobs
|
||||
SET status = $2,
|
||||
message = $3,
|
||||
error_message = '',
|
||||
completed_at = NOW(),
|
||||
heartbeat_at = NOW(),
|
||||
expires_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = $5
|
||||
RETURNING `+adminJobColumns,
|
||||
id,
|
||||
StatusCancelled,
|
||||
message,
|
||||
expiresAt,
|
||||
StatusQueued,
|
||||
))
|
||||
if err == nil {
|
||||
return job, nil
|
||||
}
|
||||
if !errors.Is(err, ErrJobNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
existing, lookupErr := r.GetByID(ctx, id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if existing.Status != StatusQueued {
|
||||
return nil, ErrJobNotCancellable
|
||||
}
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
func (r *Repository) RequeueStaleRunning(ctx context.Context, before time.Time) (int, error) {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE admin_jobs
|
||||
|
||||
@@ -57,6 +57,7 @@ type Runner struct {
|
||||
heartbeatInterval time.Duration
|
||||
staleAfter time.Duration
|
||||
retention time.Duration
|
||||
cancelRegistry *CancelRegistry
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -99,10 +100,17 @@ func NewRunner(
|
||||
heartbeatInterval: 10 * time.Second,
|
||||
staleAfter: 2 * time.Minute,
|
||||
retention: 7 * 24 * time.Hour,
|
||||
cancelRegistry: NewCancelRegistry(),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) SetCancelRegistry(registry *CancelRegistry) {
|
||||
if registry != nil {
|
||||
r.cancelRegistry = registry
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Start() {
|
||||
go func() {
|
||||
r.requeueStaleJobs()
|
||||
@@ -329,6 +337,8 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), libraryRefreshTimeout)
|
||||
defer cancel()
|
||||
unregisterCancel := r.cancelRegistry.Register(job.ID, cancel)
|
||||
defer unregisterCancel()
|
||||
|
||||
heartbeatStop := make(chan struct{})
|
||||
go r.heartbeatLoop(ctx, job.ID, heartbeatStop)
|
||||
@@ -357,6 +367,10 @@ func (r *Runner) executeLibraryRefresh(job *models.AdminJob) {
|
||||
r.publishJobByID(ctx, notifications.TypeJobProgress, job.ID)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
r.cancelJob(job.ID, current, total, "Library metadata refresh cancelled")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if ctx.Err() != nil {
|
||||
msg = fmt.Sprintf("timed out after %s: %s", libraryRefreshTimeout, msg)
|
||||
@@ -846,3 +860,21 @@ func (r *Runner) failJob(id string, current, total int, message, errorMessage st
|
||||
}
|
||||
r.publishJobByID(ctx, notifications.TypeJobFailed, id)
|
||||
}
|
||||
|
||||
func (r *Runner) cancelJob(id string, current, total int, message string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := r.repo.UpdateProgress(ctx, id, current, total, message); err != nil {
|
||||
slog.Warn("admin jobs: failed to update cancellation progress", "job_id", id, "error", err)
|
||||
}
|
||||
job, err := r.repo.Cancel(ctx, id, message, time.Now().UTC().Add(r.retention))
|
||||
if err != nil {
|
||||
slog.Warn("admin jobs: failed to mark job cancelled", "job_id", id, "error", err)
|
||||
return
|
||||
}
|
||||
if r.realtimeHub != nil {
|
||||
if err := r.realtimeHub.PublishJob(ctx, notifications.TypeJobCancelled, job); err != nil {
|
||||
slog.Warn("admin jobs: failed to publish job cancellation", "job_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+160
-41
@@ -875,6 +875,9 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
var resp AdminStats
|
||||
|
||||
if h.StatsSource != nil {
|
||||
if isTruthyQuery(r.URL.Query().Get("refresh")) {
|
||||
h.StatsSource.Invalidate()
|
||||
}
|
||||
stats, err := h.StatsSource.Get(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get stats")
|
||||
@@ -901,6 +904,15 @@ func (h *AdminHandler) HandleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func isTruthyQuery(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) invalidateStats(ctx context.Context, channel, eventType, payload string) {
|
||||
if h.StatsSource != nil {
|
||||
h.StatsSource.Invalidate()
|
||||
@@ -1257,6 +1269,9 @@ type adminDeviceDetailResponse struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DevicePlatform string `json:"device_platform"`
|
||||
OverrideCount int `json:"override_count"`
|
||||
ProfileCount int `json:"profile_count"`
|
||||
Profiles []adminDeviceProfileSummary `json:"profiles"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
Settings []adminDeviceSettingResponse `json:"settings"`
|
||||
}
|
||||
@@ -1499,6 +1514,12 @@ func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *h
|
||||
if existing != nil {
|
||||
entry.DeviceName = existing.DeviceName
|
||||
entry.DevicePlatform = existing.DevicePlatform
|
||||
} else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
} else if registered != nil {
|
||||
entry.DeviceName = registered.DeviceName
|
||||
entry.DevicePlatform = registered.DevicePlatform
|
||||
}
|
||||
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
|
||||
@@ -1621,6 +1642,10 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list device settings: %w", err)
|
||||
}
|
||||
devices, err := listRegisteredDevices(gctx, store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(gctx, store)
|
||||
if err != nil {
|
||||
slog.Warn("admin list devices profile lookup failed",
|
||||
@@ -1629,7 +1654,14 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
profileNames = map[string]string{}
|
||||
}
|
||||
perUser[i] = buildAdminDeviceSummaries(user.ID, user.Username, user.Email, entries, profileNames)
|
||||
perUser[i] = buildAdminDeviceSummaries(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Email,
|
||||
entries,
|
||||
devices,
|
||||
profileNames,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1693,6 +1725,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
registeredDevices, err := listRegisteredDevices(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
|
||||
@@ -1705,12 +1742,26 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceEntries = append(deviceEntries, entry)
|
||||
}
|
||||
}
|
||||
if len(deviceEntries) == 0 {
|
||||
deviceRegistrations := make([]userstore.DeviceEntry, 0)
|
||||
for _, entry := range registeredDevices {
|
||||
if entry.DeviceID == deviceID {
|
||||
deviceRegistrations = append(deviceRegistrations, entry)
|
||||
}
|
||||
}
|
||||
summaries := buildAdminDeviceSummaries(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Email,
|
||||
deviceEntries,
|
||||
deviceRegistrations,
|
||||
profileNames,
|
||||
)
|
||||
if len(summaries) == 0 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Device not found")
|
||||
return
|
||||
}
|
||||
|
||||
summary := buildAdminDeviceSummaries(user.ID, user.Username, user.Email, deviceEntries, profileNames)[0]
|
||||
summary := summaries[0]
|
||||
writeJSON(w, http.StatusOK, adminDeviceDetailResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
@@ -1718,11 +1769,41 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
DeviceID: summary.DeviceID,
|
||||
DeviceName: summary.DeviceName,
|
||||
DevicePlatform: summary.DevicePlatform,
|
||||
OverrideCount: summary.OverrideCount,
|
||||
ProfileCount: summary.ProfileCount,
|
||||
Profiles: summary.Profiles,
|
||||
LastUpdated: summary.LastUpdated,
|
||||
Settings: buildAdminDeviceSettingsResponse(user.ID, profileNames, deviceEntries).Settings,
|
||||
})
|
||||
}
|
||||
|
||||
func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]userstore.DeviceEntry, error) {
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return registry.ListDevices(ctx)
|
||||
}
|
||||
|
||||
func registeredDeviceForProfile(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
deviceID string,
|
||||
) (*userstore.DeviceEntry, error) {
|
||||
devices, err := listRegisteredDevices(ctx, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device.ProfileID == profileID && device.DeviceID == deviceID {
|
||||
matched := device
|
||||
return &matched, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
|
||||
resp := adminDeviceSettingsListResponse{
|
||||
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
|
||||
@@ -1748,6 +1829,7 @@ func buildAdminDeviceSummaries(
|
||||
username string,
|
||||
email string,
|
||||
entries []userstore.DeviceSettingEntry,
|
||||
registeredDevices []userstore.DeviceEntry,
|
||||
profileNames map[string]string,
|
||||
) []adminDeviceSummaryResponse {
|
||||
type profileAccumulator struct {
|
||||
@@ -1761,10 +1843,10 @@ func buildAdminDeviceSummaries(
|
||||
}
|
||||
|
||||
byDevice := make(map[string]*summary)
|
||||
for _, entry := range entries {
|
||||
deviceID := strings.TrimSpace(entry.DeviceID)
|
||||
|
||||
ensureDevice := func(deviceID, deviceName, devicePlatform, lastUpdated string) *summary {
|
||||
if deviceID == "" {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
current, ok := byDevice[deviceID]
|
||||
if !ok {
|
||||
@@ -1774,49 +1856,86 @@ func buildAdminDeviceSummaries(
|
||||
Username: username,
|
||||
Email: email,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: entry.DeviceName,
|
||||
DevicePlatform: entry.DevicePlatform,
|
||||
LastUpdated: entry.UpdatedAt,
|
||||
DeviceName: deviceName,
|
||||
DevicePlatform: devicePlatform,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
profiles: make(map[string]*profileAccumulator),
|
||||
}
|
||||
byDevice[deviceID] = current
|
||||
}
|
||||
current.keys[entry.ProfileID+":"+entry.Key] = struct{}{}
|
||||
if current.device.DeviceName == "" && deviceName != "" {
|
||||
current.device.DeviceName = deviceName
|
||||
}
|
||||
if current.device.DevicePlatform == "" && devicePlatform != "" {
|
||||
current.device.DevicePlatform = devicePlatform
|
||||
}
|
||||
if lastUpdated > current.device.LastUpdated {
|
||||
current.device.LastUpdated = lastUpdated
|
||||
if deviceName != "" {
|
||||
current.device.DeviceName = deviceName
|
||||
}
|
||||
if devicePlatform != "" {
|
||||
current.device.DevicePlatform = devicePlatform
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
ensureProfile := func(current *summary, profileID, lastUpdated string) *profileAccumulator {
|
||||
if current == nil || profileID == "" {
|
||||
return nil
|
||||
}
|
||||
profile, exists := current.profiles[profileID]
|
||||
if !exists {
|
||||
profile = &profileAccumulator{
|
||||
summary: adminDeviceProfileSummary{
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileNames[profileID],
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
}
|
||||
current.profiles[profileID] = profile
|
||||
return profile
|
||||
}
|
||||
if lastUpdated > profile.summary.LastUpdated {
|
||||
profile.summary.LastUpdated = lastUpdated
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
for _, device := range registeredDevices {
|
||||
deviceID := strings.TrimSpace(device.DeviceID)
|
||||
profileID := strings.TrimSpace(device.ProfileID)
|
||||
current := ensureDevice(
|
||||
deviceID,
|
||||
device.DeviceName,
|
||||
device.DevicePlatform,
|
||||
device.LastSeenAt,
|
||||
)
|
||||
ensureProfile(current, profileID, device.LastSeenAt)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
deviceID := strings.TrimSpace(entry.DeviceID)
|
||||
profileID := strings.TrimSpace(entry.ProfileID)
|
||||
if profileID != "" {
|
||||
profile, exists := current.profiles[profileID]
|
||||
if !exists {
|
||||
profile = &profileAccumulator{
|
||||
summary: adminDeviceProfileSummary{
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileNames[profileID],
|
||||
LastUpdated: entry.UpdatedAt,
|
||||
},
|
||||
keys: make(map[string]struct{}),
|
||||
}
|
||||
current.profiles[profileID] = profile
|
||||
}
|
||||
current := ensureDevice(
|
||||
deviceID,
|
||||
entry.DeviceName,
|
||||
entry.DevicePlatform,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if profileID != "" && entry.Key != "" {
|
||||
current.keys[profileID+":"+entry.Key] = struct{}{}
|
||||
}
|
||||
profile := ensureProfile(current, profileID, entry.UpdatedAt)
|
||||
if profile != nil && entry.Key != "" {
|
||||
profile.keys[entry.Key] = struct{}{}
|
||||
if entry.UpdatedAt > profile.summary.LastUpdated {
|
||||
profile.summary.LastUpdated = entry.UpdatedAt
|
||||
}
|
||||
}
|
||||
if current.device.DeviceName == "" && entry.DeviceName != "" {
|
||||
current.device.DeviceName = entry.DeviceName
|
||||
}
|
||||
if current.device.DevicePlatform == "" && entry.DevicePlatform != "" {
|
||||
current.device.DevicePlatform = entry.DevicePlatform
|
||||
}
|
||||
if entry.UpdatedAt > current.device.LastUpdated {
|
||||
current.device.LastUpdated = entry.UpdatedAt
|
||||
if entry.DeviceName != "" {
|
||||
current.device.DeviceName = entry.DeviceName
|
||||
}
|
||||
if entry.DevicePlatform != "" {
|
||||
current.device.DevicePlatform = entry.DevicePlatform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
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/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
const adminJobDownloadExpiry = 15 * time.Minute
|
||||
@@ -23,12 +24,22 @@ type AdminJobArtifactStore interface {
|
||||
PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type AdminJobsHandler struct {
|
||||
repo *adminjob.Repository
|
||||
store AdminJobArtifactStore
|
||||
type adminJobRepository interface {
|
||||
List(ctx context.Context, opts adminjob.ListJobsOptions) ([]*models.AdminJob, error)
|
||||
GetByID(ctx context.Context, id string) (*models.AdminJob, error)
|
||||
Cancel(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error)
|
||||
CancelQueued(ctx context.Context, id, message string, expiresAt time.Time) (*models.AdminJob, error)
|
||||
UpdateProgress(ctx context.Context, id string, current, total int, message string) error
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo *adminjob.Repository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
type AdminJobsHandler struct {
|
||||
repo adminJobRepository
|
||||
store AdminJobArtifactStore
|
||||
CancelRegistry *adminjob.CancelRegistry
|
||||
RealtimeHub *notifications.Hub
|
||||
}
|
||||
|
||||
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
|
||||
return &AdminJobsHandler{repo: repo, store: store}
|
||||
}
|
||||
|
||||
@@ -120,6 +131,105 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.repo == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "not_configured", "Admin jobs are not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Job ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
job, err := h.repo.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
|
||||
if job.JobType != adminjob.JobTypeLibraryRefresh {
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Only library metadata refresh jobs can be cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
switch job.Status {
|
||||
case adminjob.StatusQueued:
|
||||
cancelled, cancelErr := h.repo.CancelQueued(
|
||||
r.Context(),
|
||||
id,
|
||||
"Library metadata refresh cancelled",
|
||||
time.Now().UTC().Add(7*24*time.Hour),
|
||||
)
|
||||
if cancelErr != nil {
|
||||
if errors.Is(cancelErr, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(cancelErr, adminjob.ErrJobNotCancellable) {
|
||||
advanced, loadErr := h.repo.GetByID(r.Context(), id)
|
||||
if loadErr != nil {
|
||||
if errors.Is(loadErr, adminjob.ErrJobNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Job not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
if advanced.Status == adminjob.StatusRunning {
|
||||
h.requestRunningCancellation(w, r, advanced)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
|
||||
return
|
||||
}
|
||||
if h.RealtimeHub != nil {
|
||||
publishEventJob(
|
||||
r.Context(),
|
||||
h.RealtimeHub.EventsHub(),
|
||||
string(notifications.TypeJobCancelled),
|
||||
cancelled,
|
||||
)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminJobToResponse(r, cancelled, h.store))
|
||||
case adminjob.StatusRunning:
|
||||
h.requestRunningCancellation(w, r, job)
|
||||
default:
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Job is no longer cancellable")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminJobsHandler) requestRunningCancellation(w http.ResponseWriter, r *http.Request, job *models.AdminJob) {
|
||||
if h.CancelRegistry == nil || !h.CancelRegistry.Cancel(job.ID) {
|
||||
writeError(w, http.StatusConflict, "not_cancellable", "Running job is not cancellable on this node")
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpdateProgress(
|
||||
r.Context(),
|
||||
job.ID,
|
||||
job.ProgressCurrent,
|
||||
job.ProgressTotal,
|
||||
"Cancellation requested",
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request cancellation")
|
||||
return
|
||||
}
|
||||
updated, err := h.repo.GetByID(r.Context(), job.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load admin job")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, updated, h.store))
|
||||
}
|
||||
|
||||
func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArtifactStore) adminJobResponse {
|
||||
resp := adminJobResponse{
|
||||
ID: job.ID,
|
||||
|
||||
@@ -2,12 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
|
||||
@@ -79,3 +88,195 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
|
||||
t.Fatalf("ErrorMessage = %q, want stripped for non-admin", resp.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_RunningRegistryMissDoesNotUpdateProgress(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusRunning,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Running",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
|
||||
}
|
||||
if repo.updateProgressCalls != 0 {
|
||||
t.Fatalf("UpdateProgress called %d times, want 0", repo.updateProgressCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_QueuedPublishesCancelledEvent(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusQueued,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Queued",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
hub := notifications.NewHub("test", &cache.NoopEventBus{})
|
||||
events, unsubscribe := hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
handler.RealtimeHub = hub
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != notifications.TypeJobCancelled {
|
||||
t.Fatalf("event.Type = %q, want %q", event.Type, notifications.TypeJobCancelled)
|
||||
}
|
||||
if event.Job == nil || event.Job.ID != "job-1" {
|
||||
t.Fatalf("event.Job = %#v, want job-1", event.Job)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for job.cancelled event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsHandleCancel_QueuedSnapshotFallsBackToRunningCancellation(t *testing.T) {
|
||||
repo := &fakeAdminJobRepository{
|
||||
job: &models.AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: adminjob.JobTypeLibraryRefresh,
|
||||
Status: adminjob.StatusQueued,
|
||||
CreatedByUserID: 1,
|
||||
Message: "Queued",
|
||||
RequestedAt: time.Now().UTC(),
|
||||
ProgressCurrent: 3,
|
||||
ProgressTotal: 10,
|
||||
},
|
||||
promoteToRunningOnCancelQueued: true,
|
||||
}
|
||||
cancelled := false
|
||||
registry := adminjob.NewCancelRegistry()
|
||||
unregister := registry.Register("job-1", func() {
|
||||
cancelled = true
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
handler := NewAdminJobsHandler(repo, nil)
|
||||
handler.CancelRegistry = registry
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCancel(rec, adminJobCancelRequest("job-1"))
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if !cancelled {
|
||||
t.Fatal("expected running job cancel registry to be invoked")
|
||||
}
|
||||
if repo.cancelQueuedCalls != 1 {
|
||||
t.Fatalf("CancelQueued called %d times, want 1", repo.cancelQueuedCalls)
|
||||
}
|
||||
if repo.cancelCalls != 0 {
|
||||
t.Fatalf("Cancel called %d times, want 0", repo.cancelCalls)
|
||||
}
|
||||
if repo.updateProgressCalls != 1 {
|
||||
t.Fatalf("UpdateProgress called %d times, want 1", repo.updateProgressCalls)
|
||||
}
|
||||
if repo.job.Status != adminjob.StatusRunning {
|
||||
t.Fatalf("job status = %q, want %q", repo.job.Status, adminjob.StatusRunning)
|
||||
}
|
||||
if repo.job.Message != "Cancellation requested" {
|
||||
t.Fatalf("job message = %q, want cancellation request", repo.job.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func adminJobCancelRequest(id string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/jobs/"+id+"/cancel", nil)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", id)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
type fakeAdminJobRepository struct {
|
||||
job *models.AdminJob
|
||||
updateProgressCalls int
|
||||
cancelCalls int
|
||||
cancelQueuedCalls int
|
||||
promoteToRunningOnCancelQueued bool
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) List(context.Context, adminjob.ListJobsOptions) ([]*models.AdminJob, error) {
|
||||
if r.job == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []*models.AdminJob{r.job}, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) GetByID(_ context.Context, id string) (*models.AdminJob, error) {
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
cp := *r.job
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) Cancel(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) {
|
||||
r.cancelCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
if r.job.Status != adminjob.StatusQueued && r.job.Status != adminjob.StatusRunning {
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusCancelled
|
||||
cp.Message = message
|
||||
r.job = &cp
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) CancelQueued(_ context.Context, id, message string, _ time.Time) (*models.AdminJob, error) {
|
||||
r.cancelQueuedCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return nil, adminjob.ErrJobNotFound
|
||||
}
|
||||
if r.promoteToRunningOnCancelQueued {
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusRunning
|
||||
r.job = &cp
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
if r.job.Status != adminjob.StatusQueued {
|
||||
return nil, adminjob.ErrJobNotCancellable
|
||||
}
|
||||
cp := *r.job
|
||||
cp.Status = adminjob.StatusCancelled
|
||||
cp.Message = message
|
||||
r.job = &cp
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminJobRepository) UpdateProgress(_ context.Context, id string, current, total int, message string) error {
|
||||
r.updateProgressCalls++
|
||||
if r.job == nil || r.job.ID != id {
|
||||
return adminjob.ErrJobNotFound
|
||||
}
|
||||
if message == "" {
|
||||
return errors.New("message is required")
|
||||
}
|
||||
r.job.ProgressCurrent = current
|
||||
r.job.ProgressTotal = total
|
||||
r.job.Message = message
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "metadata.updated", map[string]any{
|
||||
if err := hub.PublishJSON(ctx, evt.ChannelCatalog, "catalog.item.changed", map[string]any{
|
||||
"library_id": libraryID,
|
||||
"content_id": contentID,
|
||||
"change": "metadata_updated",
|
||||
}, evt.PublishOptions{}); err != nil {
|
||||
slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err)
|
||||
}
|
||||
|
||||
@@ -216,7 +216,9 @@ func (h *PersonalDataHandler) HandleAddFavorite(w http.ResponseWriter, r *http.R
|
||||
|
||||
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventAdded, userID, profileID, itemID)
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated")
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
|
||||
IsFavorite: boolPtr(true),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -244,7 +246,9 @@ func (h *PersonalDataHandler) HandleRemoveFavorite(w http.ResponseWriter, r *htt
|
||||
|
||||
h.dispatchLocalFavoriteEvent(r.Context(), watchsync.LocalFavoriteEventRemoved, userID, profileID, itemID)
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", "favorite.updated")
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "favorite", userStateEventState{
|
||||
IsFavorite: boolPtr(false),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -366,7 +370,9 @@ func (h *PersonalDataHandler) HandleAddToWatchlist(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated")
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
|
||||
InWatchlist: boolPtr(true),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -393,7 +399,9 @@ func (h *PersonalDataHandler) HandleRemoveFromWatchlist(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", "watchlist.updated")
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, itemID, "", "watchlist", userStateEventState{
|
||||
InWatchlist: boolPtr(false),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -492,7 +500,7 @@ func (h *PersonalDataHandler) HandleRemoveHistory(w http.ResponseWriter, r *http
|
||||
mediaItemID,
|
||||
"",
|
||||
"history",
|
||||
"history.updated",
|
||||
userStateEventState{},
|
||||
)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type HomeDismissalHandler struct {
|
||||
storeProvider userstore.UserStoreProvider
|
||||
EventsHub *evt.Hub
|
||||
}
|
||||
|
||||
type upsertHomeDismissalRequest struct {
|
||||
@@ -76,6 +78,16 @@ func (h *HomeDismissalHandler) HandleUpsertDismissal(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save dismissal")
|
||||
return
|
||||
}
|
||||
publishUserStateEvent(
|
||||
r.Context(),
|
||||
h.EventsHub,
|
||||
userID,
|
||||
profileID,
|
||||
itemID,
|
||||
req.SeriesID,
|
||||
"home_dismissal",
|
||||
userStateEventState{},
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -104,6 +116,16 @@ func (h *HomeDismissalHandler) HandleDeleteDismissal(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete dismissal")
|
||||
return
|
||||
}
|
||||
publishUserStateEvent(
|
||||
r.Context(),
|
||||
h.EventsHub,
|
||||
userID,
|
||||
profileID,
|
||||
itemID,
|
||||
"",
|
||||
"home_dismissal",
|
||||
userStateEventState{},
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -476,7 +476,9 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, userID, profileID)
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", "watched.updated")
|
||||
publishUserStateEvent(r.Context(), h.EventsHub, userID, profileID, id, "", "watched", userStateEventState{
|
||||
Played: boolPtr(played),
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, watchedStateResponse{
|
||||
ContentID: id,
|
||||
|
||||
@@ -51,6 +51,10 @@ type LibraryHandler struct {
|
||||
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
|
||||
@@ -94,6 +98,27 @@ type libraryScanQueuer interface {
|
||||
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(
|
||||
@@ -1061,6 +1086,9 @@ func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.T
|
||||
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) {
|
||||
@@ -1279,6 +1307,371 @@ 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.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items
|
||||
@@ -166,6 +169,79 @@ func TestLibraryMatchRematch_DeprecatedStillRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryMetadataMatchQueueHandlers_NilFolderRepo(t *testing.T) {
|
||||
h := &LibraryHandler{MovieMatchQueueRepo: noopMovieMatchQueue{}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
name: "get",
|
||||
method: http.MethodGet,
|
||||
path: "/libraries/1/metadata-match-queue",
|
||||
handler: h.HandleGetMetadataMatchQueue,
|
||||
},
|
||||
{
|
||||
name: "retry",
|
||||
method: http.MethodPost,
|
||||
path: "/libraries/1/metadata-match-queue/retry",
|
||||
handler: h.HandleRetryMetadataMatchQueue,
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
method: http.MethodDelete,
|
||||
path: "/libraries/1/metadata-match-queue",
|
||||
handler: h.HandleCancelMetadataMatchQueue,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Method(tt.method, "/libraries/{id}/metadata-match-queue", tt.handler)
|
||||
r.Method(tt.method, "/libraries/{id}/metadata-match-queue/retry", tt.handler)
|
||||
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 for nil folderRepo, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp errorResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decoding error response: %v", err)
|
||||
}
|
||||
if resp.Error != "unavailable" {
|
||||
t.Errorf("expected error code 'unavailable', got %q", resp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type noopMovieMatchQueue struct{}
|
||||
|
||||
func (noopMovieMatchQueue) SyncForFolder(context.Context, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) CountByFolder(context.Context, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (noopMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func keys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
||||
@@ -1544,6 +1544,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress")
|
||||
return
|
||||
}
|
||||
h.syncSessionsNow(r.Context(), "progress")
|
||||
|
||||
// Persist progress to UserStore (best-effort).
|
||||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||||
@@ -1851,6 +1852,7 @@ type transcodeStartState struct {
|
||||
file *models.MediaFile
|
||||
session *playback.Session
|
||||
switchedFileID *int
|
||||
hwAccel string
|
||||
}
|
||||
|
||||
// finalizeTranscodeStart updates the playback session state after a transcode
|
||||
@@ -1891,6 +1893,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt
|
||||
TargetVideoCodec: st.req.TargetCodecVideo,
|
||||
TargetAudioCodec: st.req.TargetCodecAudio,
|
||||
TargetBitrateKbps: st.req.TargetBitrateKbps,
|
||||
TranscodeHWAccel: st.hwAccel,
|
||||
}); err != nil {
|
||||
slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID)
|
||||
}
|
||||
@@ -2065,16 +2068,35 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL)
|
||||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||||
return
|
||||
}
|
||||
var nodeResp transcodenode.TranscodeStartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil {
|
||||
slog.Warn("remote transcode start response decode failed",
|
||||
"error", err,
|
||||
"node", tcNode.URL,
|
||||
"session", req.SessionID,
|
||||
"playback_session_id", req.SessionID,
|
||||
)
|
||||
}
|
||||
effectiveHWAccel := strings.TrimSpace(nodeResp.HWAccel)
|
||||
if effectiveHWAccel == "" {
|
||||
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
|
||||
}
|
||||
|
||||
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID})
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
session: session,
|
||||
switchedFileID: switchedFileID,
|
||||
hwAccel: effectiveHWAccel,
|
||||
})
|
||||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL))
|
||||
return
|
||||
}
|
||||
@@ -2122,7 +2144,13 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
h.monitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||||
|
||||
manifestURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID)
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{req: req, file: file, session: session, switchedFileID: switchedFileID})
|
||||
h.finalizeTranscodeStart(r, transcodeStartState{
|
||||
req: req,
|
||||
file: file,
|
||||
session: session,
|
||||
switchedFileID: switchedFileID,
|
||||
hwAccel: transcodeSession.Opts().HWAccel,
|
||||
})
|
||||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type playbackSessionRow struct {
|
||||
FileDuration *int `json:"file_duration"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PositionSeconds float64 `json:"position_seconds"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
HasPlaybackControl bool `json:"has_playback_control"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
@@ -53,6 +54,7 @@ type playbackSessionRow struct {
|
||||
TargetVideoCodec string `json:"target_video_codec,omitempty"`
|
||||
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
|
||||
TargetBitrateKbps *int `json:"target_bitrate_kbps"`
|
||||
TranscodeHWAccel string `json:"transcode_hw_accel,omitempty"`
|
||||
SourceContainer string `json:"source_container,omitempty"`
|
||||
SourceBitrateKbps *int `json:"source_bitrate_kbps"`
|
||||
SourceVideoCodec string `json:"source_video_codec,omitempty"`
|
||||
@@ -143,6 +145,7 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
mf.duration,
|
||||
s.started_at,
|
||||
s.updated_at,
|
||||
COALESCE(s.position_seconds, 0),
|
||||
COALESCE(s.is_paused, FALSE),
|
||||
COALESCE(s.has_websocket, FALSE),
|
||||
COALESCE(HOST(s.client_ip), ''),
|
||||
@@ -154,6 +157,7 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
COALESCE(s.target_video_codec, ''),
|
||||
COALESCE(s.target_audio_codec, ''),
|
||||
s.target_bitrate_kbps,
|
||||
COALESCE(s.transcode_hw_accel, ''),
|
||||
COALESCE(mf.container, ''),
|
||||
mf.bitrate,
|
||||
COALESCE(mf.codec_video, ''),
|
||||
@@ -199,9 +203,9 @@ func (l *PlaybackSessionsLoader) Load(
|
||||
&s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber,
|
||||
&posterPath,
|
||||
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
|
||||
&s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
|
||||
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
|
||||
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
|
||||
&s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
|
||||
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
|
||||
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning playback session: %w", err)
|
||||
@@ -314,7 +318,7 @@ func sessionComponentDecision(playMethod string, transcodeAudio bool, targetVide
|
||||
}
|
||||
audioDec := "transcode"
|
||||
if !transcodeAudio {
|
||||
audioDec = "direct"
|
||||
audioDec = "remux"
|
||||
}
|
||||
return videoDec, audioDec
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSessionComponentDecisionLabelsCopiedAudioDuringHLSAsRemux(t *testing.T) {
|
||||
videoDecision, audioDecision := sessionComponentDecision("transcode", false, "copy")
|
||||
|
||||
if videoDecision != "remux" {
|
||||
t.Fatalf("videoDecision = %q, want remux", videoDecision)
|
||||
}
|
||||
if audioDecision != "remux" {
|
||||
t.Fatalf("audioDecision = %q, want remux", audioDecision)
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ
|
||||
item.MediaItemID,
|
||||
"",
|
||||
"progress",
|
||||
"progress.updated",
|
||||
userStateEventState{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -372,6 +373,7 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http.
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key)
|
||||
if err != nil {
|
||||
@@ -471,6 +473,7 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
|
||||
@@ -500,6 +503,7 @@ func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
device := deviceMetadataFromRequest(r)
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
keys := parseSettingKeys(keysParam)
|
||||
resp := effectiveSettingsResponse{
|
||||
Settings: make([]effectiveSettingResponse, 0, len(keys)),
|
||||
@@ -530,6 +534,7 @@ func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWr
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
||||
return
|
||||
}
|
||||
registerRequestDevice(r.Context(), store, profileID, device)
|
||||
|
||||
resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey)
|
||||
if err != nil {
|
||||
@@ -590,6 +595,36 @@ func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
func registerRequestDevice(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
device requestDeviceMetadata,
|
||||
) {
|
||||
if strings.TrimSpace(profileID) == "" || strings.TrimSpace(device.DeviceID) == "" {
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := registry.RegisterDevice(ctx, userstore.DeviceEntry{
|
||||
ProfileID: profileID,
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DeviceName,
|
||||
DevicePlatform: device.DevicePlatform,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to register request device",
|
||||
"profile_id", profileID,
|
||||
"device_id", device.DeviceID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func clampHeaderValue(value string, maxLen int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
|
||||
@@ -47,6 +47,14 @@ func (r testAdminUserRepo) GetByID(_ context.Context, id int) (*models.User, err
|
||||
return r.users[id], nil
|
||||
}
|
||||
|
||||
func TestRegisterRequestDeviceNilStore(t *testing.T) {
|
||||
registerRequestDevice(context.Background(), nil, "profile-1", requestDeviceMetadata{
|
||||
DeviceID: "device-1",
|
||||
DeviceName: "Living Room",
|
||||
DevicePlatform: "web",
|
||||
})
|
||||
}
|
||||
|
||||
type mappedTestUserStoreProvider struct {
|
||||
stores map[int]userstore.UserStore
|
||||
}
|
||||
@@ -472,6 +480,18 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("SetDeviceSetting store1 second: %v", err)
|
||||
}
|
||||
store1Registry, ok := store1.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
t.Fatalf("store1 does not support device registry")
|
||||
}
|
||||
if err := store1Registry.RegisterDevice(context.Background(), userstore.DeviceEntry{
|
||||
ProfileID: "profile-1",
|
||||
DeviceID: "bedroom",
|
||||
DeviceName: "Bedroom TV",
|
||||
DevicePlatform: "Android TV",
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterDevice store1: %v", err)
|
||||
}
|
||||
if err := store2.SetDeviceSetting(context.Background(), userstore.DeviceSettingEntry{
|
||||
ProfileID: "profile-1",
|
||||
DeviceID: "phone",
|
||||
@@ -508,8 +528,21 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
if err := json.NewDecoder(rec.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(listResp.Devices) != 2 {
|
||||
t.Fatalf("devices len = %d, want 2", len(listResp.Devices))
|
||||
if len(listResp.Devices) != 3 {
|
||||
t.Fatalf("devices len = %d, want 3", len(listResp.Devices))
|
||||
}
|
||||
var bedroom *adminDeviceSummaryResponse
|
||||
for i := range listResp.Devices {
|
||||
if listResp.Devices[i].DeviceID == "bedroom" {
|
||||
bedroom = &listResp.Devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if bedroom == nil {
|
||||
t.Fatalf("registered device without overrides missing: %#v", listResp.Devices)
|
||||
}
|
||||
if bedroom.OverrideCount != 0 || bedroom.ProfileCount != 1 || bedroom.DeviceName != "Bedroom TV" {
|
||||
t.Fatalf("registered device summary = %#v", bedroom)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/living-room", nil)
|
||||
@@ -529,6 +562,26 @@ func TestAdminCanListAndInspectDevicesAcrossUsers(t *testing.T) {
|
||||
if len(detailResp.Settings) != 2 {
|
||||
t.Fatalf("detail settings len = %d, want 2", len(detailResp.Settings))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/devices/7/bedroom", nil)
|
||||
req = withRouteParams(req, map[string]string{"user_id": "7", "device_id": "bedroom"})
|
||||
rec = httptest.NewRecorder()
|
||||
handler.HandleGetDevice(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("registered detail status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&detailResp); err != nil {
|
||||
t.Fatalf("decode registered detail: %v", err)
|
||||
}
|
||||
if detailResp.DeviceName != "Bedroom TV" || detailResp.OverrideCount != 0 {
|
||||
t.Fatalf("registered detail response = %#v", detailResp)
|
||||
}
|
||||
if len(detailResp.Settings) != 0 {
|
||||
t.Fatalf("registered detail settings len = %d, want 0", len(detailResp.Settings))
|
||||
}
|
||||
if len(detailResp.Profiles) != 1 || detailResp.Profiles[0].ProfileID != "profile-1" {
|
||||
t.Fatalf("registered detail profiles = %#v", detailResp.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) {
|
||||
@@ -559,6 +612,24 @@ func TestAdminCanResetAllOverridesForOneDevice(t *testing.T) {
|
||||
if len(entries) != 1 || entries[0].DeviceID != "phone" {
|
||||
t.Fatalf("entries after delete = %#v", entries)
|
||||
}
|
||||
registry, ok := store.(userstore.DeviceRegistry)
|
||||
if !ok {
|
||||
t.Fatalf("store does not support device registry")
|
||||
}
|
||||
devices, err := registry.ListDevices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListDevices: %v", err)
|
||||
}
|
||||
foundLivingRoom := false
|
||||
for _, device := range devices {
|
||||
if device.ProfileID == "profile-1" && device.DeviceID == "living-room" {
|
||||
foundLivingRoom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLivingRoom {
|
||||
t.Fatalf("registry devices after delete = %#v", devices)
|
||||
}
|
||||
}
|
||||
|
||||
func withRouteParams(req *http.Request, params map[string]string) *http.Request {
|
||||
|
||||
@@ -7,26 +7,41 @@ import (
|
||||
)
|
||||
|
||||
type userStateEventPayload struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
ContentID string `json:"content_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
Change string `json:"change"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ContentID string `json:"content_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
Change string `json:"change"`
|
||||
Played *bool `json:"played,omitempty"`
|
||||
IsFavorite *bool `json:"is_favorite,omitempty"`
|
||||
InWatchlist *bool `json:"in_watchlist,omitempty"`
|
||||
}
|
||||
|
||||
type userStateEventState struct {
|
||||
Played *bool
|
||||
IsFavorite *bool
|
||||
InWatchlist *bool
|
||||
}
|
||||
|
||||
const userStateChangedEvent = "user_state.changed"
|
||||
|
||||
func publishUserStateEvent(
|
||||
ctx context.Context,
|
||||
hub *evt.Hub,
|
||||
userID int,
|
||||
profileID, contentID, seriesID, change, eventName string,
|
||||
profileID, contentID, seriesID, change string,
|
||||
state userStateEventState,
|
||||
) {
|
||||
if hub == nil || userID == 0 || profileID == "" || change == "" || eventName == "" {
|
||||
if hub == nil || userID == 0 || profileID == "" || change == "" {
|
||||
return
|
||||
}
|
||||
_ = hub.PublishJSON(ctx, evt.ChannelUserState, eventName, userStateEventPayload{
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
SeriesID: seriesID,
|
||||
Change: change,
|
||||
_ = hub.PublishJSON(ctx, evt.ChannelUserState, userStateChangedEvent, userStateEventPayload{
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
SeriesID: seriesID,
|
||||
Change: change,
|
||||
Played: state.Played,
|
||||
IsFavorite: state.IsFavorite,
|
||||
InWatchlist: state.InWatchlist,
|
||||
}, evt.PublishOptions{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
|
||||
+22
-5
@@ -89,11 +89,13 @@ type Dependencies struct {
|
||||
SessionMgr *playback.SessionManager // playback session manager (may be nil)
|
||||
SkippedRootRepo *metadata.SkippedRootRepository // skipped root repository (may be nil)
|
||||
StaleIDRepo *metadata.StaleMediaIDRepository // stale media ID repository (may be nil)
|
||||
Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil)
|
||||
NodeRepo *nodepool.Repository // stream node repository (may be nil)
|
||||
ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil)
|
||||
TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil)
|
||||
SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger
|
||||
MovieMatchQueueRepo *metadata.MovieMatchQueueRepository
|
||||
SeriesRootMatchQueueRepo *metadata.SeriesRootMatchQueueRepository
|
||||
Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil)
|
||||
NodeRepo *nodepool.Repository // stream node repository (may be nil)
|
||||
ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil)
|
||||
TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil)
|
||||
SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger
|
||||
EventBus cache.EventBus
|
||||
AdminStatsProvider handlers.AdminStatsSource
|
||||
Recommender recommendations.Recommender // nil when disabled
|
||||
@@ -116,6 +118,7 @@ type Dependencies struct {
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
RedisClient *redis.Client // for session listing (may be nil)
|
||||
TaskManager *taskmanager.TaskManager // task manager (may be nil)
|
||||
AdminJobCancelRegistry *adminjob.CancelRegistry
|
||||
IntroRepository *intromarkers.Repository
|
||||
IntroAnalyzer *intromarkers.Analyzer
|
||||
MarkerRegistry *markers.Registry
|
||||
@@ -287,6 +290,12 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
libraryHandler.EventsHub = deps.EventsHub
|
||||
libraryHandler.ScanRegistry = deps.ScanRegistry
|
||||
libraryHandler.ScanQueue = deps.LibraryScanQueue
|
||||
libraryHandler.MovieMatchQueueRepo = deps.MovieMatchQueueRepo
|
||||
libraryHandler.SeriesMatchQueueRepo = deps.SeriesRootMatchQueueRepo
|
||||
libraryHandler.RawMatchBacklogRepo = deps.FileRepo
|
||||
if deps.Config != nil {
|
||||
libraryHandler.TVSeriesRootQueue = deps.Config.Matcher.TVSeriesRootQueueEnabled()
|
||||
}
|
||||
if deps.DB != nil {
|
||||
libraryHandler.JobRepo = adminjob.NewRepository(deps.DB)
|
||||
}
|
||||
@@ -499,6 +508,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
settingsHandler.SetServerSettings(settingsRepo)
|
||||
}
|
||||
homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider)
|
||||
homeDismissalHandler.EventsHub = deps.EventsHub
|
||||
subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider)
|
||||
audioPrefHandler = handlers.NewAudioPrefHandler(deps.UserStoreProvider)
|
||||
libraryPlaybackPrefHandler = handlers.NewLibraryPlaybackPrefHandler(deps.UserStoreProvider)
|
||||
@@ -706,6 +716,8 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
catalogSeedHandler = handlers.NewCatalogSeedHandler(catalogseed.NewService(deps.DB, deps.PersonRepo, recommendations.NewRepo(deps.DB)), jobRepo, privateStore)
|
||||
catalogSeedHandler.RealtimeHub = deps.RealtimeHub
|
||||
adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore)
|
||||
adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry
|
||||
adminJobsHandler.RealtimeHub = deps.RealtimeHub
|
||||
if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil {
|
||||
adminHandler.JobRepo = jobRepo
|
||||
adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver(
|
||||
@@ -1301,12 +1313,16 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/stale-ids", libraryHandler.HandleListStaleIDs)
|
||||
r.Post("/stale-ids/{contentID}/rematch", libraryHandler.HandleRematchStaleID)
|
||||
r.Get("/unmatched-items", libraryHandler.HandleListUnmatchedItems)
|
||||
r.Get("/metadata-match-queue", libraryHandler.HandleListMetadataMatchQueues)
|
||||
r.Post("/", libraryHandler.HandleCreateLibrary)
|
||||
r.Put("/reorder", libraryHandler.HandleReorderLibraries)
|
||||
r.Put("/{id}", libraryHandler.HandleUpdateLibrary)
|
||||
r.Delete("/{id}", libraryHandler.HandleDeleteLibrary)
|
||||
r.Post("/{id}/check-mount", libraryHandler.HandleCheckLibraryMount)
|
||||
r.Post("/{id}/confirm-empty-root-cleanup", libraryHandler.HandleConfirmEmptyRootCleanup)
|
||||
r.Get("/{id}/metadata-match-queue", libraryHandler.HandleGetMetadataMatchQueue)
|
||||
r.Post("/{id}/metadata-match-queue/retry", libraryHandler.HandleRetryMetadataMatchQueue)
|
||||
r.Post("/{id}/metadata-match-queue/cancel", libraryHandler.HandleCancelMetadataMatchQueue)
|
||||
r.Post("/{id}/refresh-metadata", libraryHandler.HandleRefreshLibraryMetadata)
|
||||
r.Get("/{id}/providers", libraryHandler.HandleGetLibraryProviders)
|
||||
r.Put("/{id}/providers", libraryHandler.HandleSetLibraryProviders)
|
||||
@@ -1855,6 +1871,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
if adminJobsHandler != nil {
|
||||
r.Route("/jobs", func(r chi.Router) {
|
||||
r.Get("/", adminJobsHandler.HandleList)
|
||||
r.Post("/{id}/cancel", adminJobsHandler.HandleCancel)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -358,8 +358,9 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode
|
||||
}
|
||||
}
|
||||
if shouldPublish(result) && e.realtime != nil {
|
||||
if err := e.realtime.PublishLibraryChanged(scanCtx, notifications.LibraryChangeEvent{
|
||||
if err := e.realtime.PublishCatalogLibraryChanged(scanCtx, notifications.LibraryChangeEvent{
|
||||
LibraryID: folder.ID,
|
||||
Reason: "scan",
|
||||
New: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.New }),
|
||||
Updated: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.Updated }),
|
||||
Missing: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.Missing }),
|
||||
|
||||
@@ -450,6 +450,23 @@ func (r *MovieMatchQueueRepository) Delete(ctx context.Context, mediaFileID int)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MovieMatchQueueRepository) DeleteByFolder(ctx context.Context, folderID int) (int, error) {
|
||||
if err := r.requireConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := requirePositiveMovieQueueID("folder id", folderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM movie_match_queue
|
||||
WHERE media_folder_id = $1
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting movie queue rows for folder: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID int, errText string) error {
|
||||
if err := r.requireConfigured(); err != nil {
|
||||
return err
|
||||
@@ -485,10 +502,20 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT media_file_id, media_folder_id, first_queued_at, available_at, last_attempted_at, attempt_count, last_error, updated_at
|
||||
FROM movie_match_queue
|
||||
WHERE media_folder_id = $1
|
||||
ORDER BY available_at ASC, last_attempted_at ASC NULLS FIRST, media_file_id ASC
|
||||
SELECT
|
||||
q.media_file_id,
|
||||
q.media_folder_id,
|
||||
COALESCE(mf.file_path, '') AS file_path,
|
||||
q.first_queued_at,
|
||||
q.available_at,
|
||||
q.last_attempted_at,
|
||||
q.attempt_count,
|
||||
q.last_error,
|
||||
q.updated_at
|
||||
FROM movie_match_queue q
|
||||
LEFT JOIN media_files mf ON mf.id = q.media_file_id
|
||||
WHERE q.media_folder_id = $1
|
||||
ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_file_id ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, folderID, limit, offset)
|
||||
if err != nil {
|
||||
@@ -502,6 +529,7 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i
|
||||
if err := rows.Scan(
|
||||
&entry.MediaFileID,
|
||||
&entry.MediaFolderID,
|
||||
&entry.FilePath,
|
||||
&entry.FirstQueuedAt,
|
||||
&entry.AvailableAt,
|
||||
&entry.LastAttemptedAt,
|
||||
|
||||
@@ -520,6 +520,23 @@ func (r *SeriesRootMatchQueueRepository) Delete(ctx context.Context, folderID in
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SeriesRootMatchQueueRepository) DeleteByFolder(ctx context.Context, folderID int) (int, error) {
|
||||
if err := r.requireConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := requirePositiveSeriesQueueID("folder id", folderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM series_root_match_queue
|
||||
WHERE media_folder_id = $1
|
||||
`, folderID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting series root queue rows for folder: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folderID int, observedRootPath string, errText string) error {
|
||||
if err := r.requireConfigured(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
)
|
||||
|
||||
// MatchWorker processes unmatched files in the background.
|
||||
@@ -25,6 +26,7 @@ type MatchWorker struct {
|
||||
movieClaimer MovieFileClaimer
|
||||
seriesClaimer SeriesRootClaimer
|
||||
enableTVSeriesRootQueue bool
|
||||
realtimeHub *notifications.Hub
|
||||
workers int
|
||||
batchSize int
|
||||
interval time.Duration
|
||||
@@ -40,6 +42,10 @@ type MixedFileClaimer interface {
|
||||
ClaimUnmatchedMixedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
||||
}
|
||||
|
||||
type MatchSuppressionChecker interface {
|
||||
IsMatchSuppressed(ctx context.Context, fileID int) (bool, error)
|
||||
}
|
||||
|
||||
type MovieFileClaimer interface {
|
||||
Claim(ctx context.Context, limit int) ([]*models.MediaFile, error)
|
||||
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
||||
@@ -98,6 +104,14 @@ func (w *MatchWorker) SetMovieFileClaimer(claimer MovieFileClaimer) {
|
||||
w.movieClaimer = claimer
|
||||
}
|
||||
|
||||
// SetRealtimeHub publishes catalog item changes as metadata enrichment commits.
|
||||
func (w *MatchWorker) SetRealtimeHub(hub *notifications.Hub) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.realtimeHub = hub
|
||||
}
|
||||
|
||||
// Run starts the match worker loop. It blocks until ctx is cancelled.
|
||||
func (w *MatchWorker) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
@@ -234,7 +248,9 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode
|
||||
"content_id", skeleton.ContentID, "error", fbErr)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
||||
}
|
||||
|
||||
func (w *MatchWorker) buildProcessRequestForGroup(ctx context.Context, representative *models.MediaFile, skeleton *skeletonResult, preloadedGroupFiles []*models.MediaFile) ProcessRequest {
|
||||
@@ -492,6 +508,9 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if w.isMatchSuppressed(ctx, file) {
|
||||
continue
|
||||
}
|
||||
w.processFileWithFolderCache(ctx, file, &folders, &deferredSeriesLinks)
|
||||
processed.Add(1)
|
||||
}
|
||||
@@ -517,6 +536,32 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil
|
||||
return claimedCount
|
||||
}
|
||||
|
||||
func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaFile) bool {
|
||||
if file == nil {
|
||||
return true
|
||||
}
|
||||
checker, ok := w.fileLister.(MatchSuppressionChecker)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
suppressed, err := checker.IsMatchSuppressed(ctx, file.ID)
|
||||
if err != nil {
|
||||
slog.Warn("metadata: failed to check match suppression",
|
||||
"file_id", file.ID,
|
||||
"path", file.FilePath,
|
||||
"error", err,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if suppressed {
|
||||
slog.Info("metadata: skipping suppressed unmatched file",
|
||||
"file_id", file.ID,
|
||||
"path", file.FilePath,
|
||||
)
|
||||
}
|
||||
return suppressed
|
||||
}
|
||||
|
||||
func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*models.MediaFile) int {
|
||||
if len(files) == 0 {
|
||||
return 0
|
||||
@@ -627,6 +672,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
|
||||
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
|
||||
return false
|
||||
}
|
||||
w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
||||
}
|
||||
|
||||
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
|
||||
@@ -837,6 +883,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
||||
}
|
||||
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
|
||||
if errors.Is(err, catalog.ErrItemNotFound) {
|
||||
@@ -932,6 +979,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
||||
}
|
||||
|
||||
finalContentID, err := w.service.fileRepo.FindContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, "series")
|
||||
@@ -1242,7 +1290,9 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte
|
||||
}
|
||||
if result == nil || !result.Updated {
|
||||
stillUnmatched++
|
||||
continue
|
||||
}
|
||||
w.publishCatalogItemChanged(ctx, folderID, resultContentID(result, contentID), "metadata_updated")
|
||||
}
|
||||
|
||||
if repaired, repairErr := w.service.repairMatchedDuplicateProviderOwnersByFolderAndPathPrefix(ctx, folderID, pathPrefix); repairErr != nil {
|
||||
@@ -1262,3 +1312,28 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte
|
||||
func formatFolderID(id int) string {
|
||||
return strconv.Itoa(id)
|
||||
}
|
||||
|
||||
func (w *MatchWorker) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string, change string) {
|
||||
if w == nil || w.realtimeHub == nil || libraryID <= 0 || strings.TrimSpace(contentID) == "" {
|
||||
return
|
||||
}
|
||||
if err := w.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
||||
LibraryID: libraryID,
|
||||
ContentID: contentID,
|
||||
Change: change,
|
||||
}); err != nil {
|
||||
slog.Warn("metadata: failed to publish catalog item change",
|
||||
"content_id", contentID,
|
||||
"library_id", libraryID,
|
||||
"change", change,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func resultContentID(result *ProcessResult, fallback string) string {
|
||||
if result != nil && strings.TrimSpace(result.ContentID) != "" {
|
||||
return result.ContentID
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -7,25 +7,25 @@ import (
|
||||
|
||||
// AdminJob represents a reusable background job row for admin workflows.
|
||||
type AdminJob struct {
|
||||
ID string
|
||||
JobType string
|
||||
Status string
|
||||
CreatedByUserID int
|
||||
RequestPayload json.RawMessage
|
||||
ResultPayload json.RawMessage
|
||||
Message string
|
||||
ErrorMessage string
|
||||
ProgressCurrent int
|
||||
ProgressTotal int
|
||||
ArtifactBucket string
|
||||
ArtifactKey string
|
||||
ArtifactSizeBytes int64
|
||||
PublicURL string
|
||||
RequestedAt time.Time
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
HeartbeatAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
PublishedAt *time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string `json:"id"`
|
||||
JobType string `json:"job_type"`
|
||||
Status string `json:"status"`
|
||||
CreatedByUserID int `json:"created_by_user_id"`
|
||||
RequestPayload json.RawMessage `json:"request_payload"`
|
||||
ResultPayload json.RawMessage `json:"result_payload"`
|
||||
Message string `json:"message"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ProgressCurrent int `json:"progress_current"`
|
||||
ProgressTotal int `json:"progress_total"`
|
||||
ArtifactBucket string `json:"-"`
|
||||
ArtifactKey string `json:"-"`
|
||||
ArtifactSizeBytes int64 `json:"artifact_size_bytes"`
|
||||
PublicURL string `json:"public_url,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
HeartbeatAt *time.Time `json:"heartbeat_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminJobJSONUsesAPIFieldNames(t *testing.T) {
|
||||
job := AdminJob{
|
||||
ID: "job-1",
|
||||
JobType: "library_refresh",
|
||||
Status: "running",
|
||||
CreatedByUserID: 7,
|
||||
RequestPayload: json.RawMessage(`{"library_id":42}`),
|
||||
ResultPayload: json.RawMessage(`{}`),
|
||||
Message: "Refreshing metadata",
|
||||
ProgressCurrent: 3,
|
||||
ProgressTotal: 10,
|
||||
RequestedAt: time.Unix(1_700_000_000, 0).UTC(),
|
||||
UpdatedAt: time.Unix(1_700_000_001, 0).UTC(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"id",
|
||||
"job_type",
|
||||
"created_by_user_id",
|
||||
"request_payload",
|
||||
"progress_current",
|
||||
"progress_total",
|
||||
"requested_at",
|
||||
"updated_at",
|
||||
} {
|
||||
if _, ok := payload[key]; !ok {
|
||||
t.Fatalf("marshaled job missing key %q in %s", key, data)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"ID", "JobType", "CreatedByUserID", "ProgressCurrent"} {
|
||||
if _, ok := payload[key]; ok {
|
||||
t.Fatalf("marshaled job unexpectedly included key %q in %s", key, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ type SeriesRootMatchJob struct {
|
||||
type MovieMatchQueueEntry struct {
|
||||
MediaFileID int
|
||||
MediaFolderID int
|
||||
FilePath string
|
||||
FirstQueuedAt time.Time
|
||||
AvailableAt time.Time
|
||||
LastAttemptedAt *time.Time
|
||||
|
||||
@@ -13,13 +13,17 @@ import (
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeLibraryChanged Type = "library.changed"
|
||||
TypeLibraryItemAdded Type = "library.item_added"
|
||||
TypeMetadataUpdated Type = "metadata.updated"
|
||||
TypeCatalogLibraryChanged Type = "catalog.library.changed"
|
||||
TypeCatalogItemChanged Type = "catalog.item.changed"
|
||||
|
||||
TypeLibraryChanged Type = "library.changed" // legacy catalog event name
|
||||
TypeLibraryItemAdded Type = "library.item_added" // legacy catalog event name
|
||||
TypeMetadataUpdated Type = "metadata.updated" // legacy catalog event name
|
||||
TypeJobCreated Type = "job.created"
|
||||
TypeJobProgress Type = "job.progress"
|
||||
TypeJobCompleted Type = "job.completed"
|
||||
TypeJobFailed Type = "job.failed"
|
||||
TypeJobCancelled Type = "job.cancelled"
|
||||
)
|
||||
|
||||
type Envelope struct {
|
||||
@@ -29,6 +33,8 @@ type Envelope struct {
|
||||
AdminOnly bool `json:"admin_only,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
ContentID string `json:"content_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Change string `json:"change,omitempty"`
|
||||
New int `json:"new,omitempty"`
|
||||
Updated int `json:"updated,omitempty"`
|
||||
Missing int `json:"missing,omitempty"`
|
||||
@@ -40,6 +46,7 @@ type Envelope struct {
|
||||
|
||||
type LibraryChangeEvent struct {
|
||||
LibraryID int
|
||||
Reason string
|
||||
New int
|
||||
Updated int
|
||||
Missing int
|
||||
@@ -51,6 +58,7 @@ type LibraryChangeEvent struct {
|
||||
type MetadataUpdateEvent struct {
|
||||
LibraryID int
|
||||
ContentID string
|
||||
Change string
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
@@ -95,15 +103,7 @@ func (h *Hub) PublishLibraryChanged(ctx context.Context, event LibraryChangeEven
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"library_id": event.LibraryID,
|
||||
"new": event.New,
|
||||
"updated": event.Updated,
|
||||
"missing": event.Missing,
|
||||
"matched_files": event.MatchedFiles,
|
||||
"retried_items": event.RetriedItems,
|
||||
"still_unmatched_warnings": event.StillUnmatchedWarnings,
|
||||
}
|
||||
payload := libraryChangePayload(event)
|
||||
if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeLibraryChanged), payload, evt.PublishOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,6 +116,16 @@ func (h *Hub) PublishLibraryChanged(ctx context.Context, event LibraryChangeEven
|
||||
}, evt.PublishOptions{})
|
||||
}
|
||||
|
||||
func (h *Hub) PublishCatalogLibraryChanged(ctx context.Context, event LibraryChangeEvent) error {
|
||||
if h == nil || h.inner == nil {
|
||||
return nil
|
||||
}
|
||||
if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeCatalogLibraryChanged), libraryChangePayload(event), evt.PublishOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.PublishLibraryChanged(ctx, event)
|
||||
}
|
||||
|
||||
func (h *Hub) PublishMetadataUpdated(ctx context.Context, event MetadataUpdateEvent) error {
|
||||
if h == nil || h.inner == nil {
|
||||
return nil
|
||||
@@ -126,6 +136,24 @@ func (h *Hub) PublishMetadataUpdated(ctx context.Context, event MetadataUpdateEv
|
||||
}, evt.PublishOptions{})
|
||||
}
|
||||
|
||||
func (h *Hub) PublishCatalogItemChanged(ctx context.Context, event MetadataUpdateEvent) error {
|
||||
if h == nil || h.inner == nil {
|
||||
return nil
|
||||
}
|
||||
change := event.Change
|
||||
if change == "" {
|
||||
change = "metadata_updated"
|
||||
}
|
||||
if err := h.inner.PublishJSON(ctx, evt.ChannelCatalog, string(TypeCatalogItemChanged), map[string]any{
|
||||
"library_id": event.LibraryID,
|
||||
"content_id": event.ContentID,
|
||||
"change": change,
|
||||
}, evt.PublishOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.PublishMetadataUpdated(ctx, event)
|
||||
}
|
||||
|
||||
func (h *Hub) PublishJob(ctx context.Context, eventType Type, job *models.AdminJob) error {
|
||||
if h == nil || h.inner == nil || job == nil {
|
||||
return nil
|
||||
@@ -142,6 +170,23 @@ func (h *Hub) EventsHub() *evt.Hub {
|
||||
return h.inner
|
||||
}
|
||||
|
||||
func libraryChangePayload(event LibraryChangeEvent) map[string]any {
|
||||
reason := event.Reason
|
||||
if reason == "" {
|
||||
reason = "scan"
|
||||
}
|
||||
return map[string]any{
|
||||
"library_id": event.LibraryID,
|
||||
"reason": reason,
|
||||
"new": event.New,
|
||||
"updated": event.Updated,
|
||||
"missing": event.Missing,
|
||||
"matched_files": event.MatchedFiles,
|
||||
"retried_items": event.RetriedItems,
|
||||
"still_unmatched_warnings": event.StillUnmatchedWarnings,
|
||||
}
|
||||
}
|
||||
|
||||
func convertEnvelope(env evt.Envelope) (Envelope, bool) {
|
||||
converted := Envelope{
|
||||
Type: Type(env.Event),
|
||||
@@ -157,6 +202,8 @@ func convertEnvelope(env evt.Envelope) (Envelope, bool) {
|
||||
var payload struct {
|
||||
LibraryID int `json:"library_id"`
|
||||
ContentID string `json:"content_id"`
|
||||
Reason string `json:"reason"`
|
||||
Change string `json:"change"`
|
||||
New int `json:"new"`
|
||||
Updated int `json:"updated"`
|
||||
Missing int `json:"missing"`
|
||||
@@ -169,6 +216,8 @@ func convertEnvelope(env evt.Envelope) (Envelope, bool) {
|
||||
}
|
||||
converted.LibraryID = payload.LibraryID
|
||||
converted.ContentID = payload.ContentID
|
||||
converted.Reason = payload.Reason
|
||||
converted.Change = payload.Change
|
||||
converted.New = payload.New
|
||||
converted.Updated = payload.Updated
|
||||
converted.Missing = payload.Missing
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
)
|
||||
|
||||
func TestPublishCatalogItemChangedAlsoPublishesLegacyMetadataUpdated(t *testing.T) {
|
||||
hub := NewHub("test", &cache.NoopEventBus{})
|
||||
events, unsubscribe := hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
err := hub.PublishCatalogItemChanged(context.Background(), MetadataUpdateEvent{
|
||||
LibraryID: 12,
|
||||
ContentID: "item-1",
|
||||
Change: "metadata_updated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishCatalogItemChanged() error = %v", err)
|
||||
}
|
||||
|
||||
got := map[Type]Envelope{}
|
||||
for len(got) < 2 {
|
||||
select {
|
||||
case event := <-events:
|
||||
got[event.Type] = event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for events, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, eventType := range []Type{TypeCatalogItemChanged, TypeMetadataUpdated} {
|
||||
event, ok := got[eventType]
|
||||
if !ok {
|
||||
t.Fatalf("missing event %q in %#v", eventType, got)
|
||||
}
|
||||
if event.LibraryID != 12 || event.ContentID != "item-1" {
|
||||
t.Fatalf("event %q payload = library %d content %q", eventType, event.LibraryID, event.ContentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type Session struct {
|
||||
TargetVideoCodec string // requested output video codec for transcodes
|
||||
TargetAudioCodec string // requested output audio codec when audio is transcoded
|
||||
TargetBitrateKbps int // requested output bitrate cap for transcodes
|
||||
TranscodeHWAccel string // effective hardware acceleration mode for transcodes
|
||||
|
||||
Position float64
|
||||
IsPaused bool
|
||||
@@ -54,6 +55,7 @@ type SessionStreamState struct {
|
||||
TargetVideoCodec string
|
||||
TargetAudioCodec string
|
||||
TargetBitrateKbps int
|
||||
TranscodeHWAccel string
|
||||
}
|
||||
|
||||
// SessionManager tracks active playback sessions and enforces stream limits.
|
||||
@@ -299,6 +301,7 @@ func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStream
|
||||
s.TargetVideoCodec = state.TargetVideoCodec
|
||||
s.TargetAudioCodec = state.TargetAudioCodec
|
||||
s.TargetBitrateKbps = state.TargetBitrateKbps
|
||||
s.TranscodeHWAccel = state.TranscodeHWAccel
|
||||
m.touchSessionLocked(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
if opts.SegmentDuration <= 0 {
|
||||
opts.SegmentDuration = defaultSegmentDuration
|
||||
}
|
||||
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
|
||||
|
||||
// Ensure output directory exists.
|
||||
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||
@@ -219,18 +220,10 @@ func IsMPEG4Part2VideoCodec(codec string) bool {
|
||||
func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
// Resolve "auto" into a concrete accel method once so all downstream
|
||||
// helpers (appendHWAccelArgs, appendVideoArgs, etc.) see the real value.
|
||||
opts.HWAccel = ResolveHWAccel(opts.HWAccel)
|
||||
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
|
||||
|
||||
isVideoCopy := opts.TargetCodecVideo == "copy"
|
||||
isAudioCopy := opts.TargetCodecAudio == "copy"
|
||||
if !isVideoCopy && IsMPEG4Part2VideoCodec(opts.SourceVideoCodec) && opts.HWAccel != "none" {
|
||||
slog.Info("disabling hardware video transcode for MPEG-4 Part 2 source",
|
||||
"source_video_codec", opts.SourceVideoCodec,
|
||||
"requested_hw_accel", opts.HWAccel,
|
||||
"playback_session_id", opts.SessionID,
|
||||
)
|
||||
opts.HWAccel = "none"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
@@ -355,6 +348,20 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
|
||||
hwAccel := ResolveHWAccel(opts.HWAccel)
|
||||
if hwAccel == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(opts.TargetCodecVideo, "copy") {
|
||||
return "none"
|
||||
}
|
||||
if IsMPEG4Part2VideoCodec(opts.SourceVideoCodec) {
|
||||
return "none"
|
||||
}
|
||||
return hwAccel
|
||||
}
|
||||
|
||||
// appendStreamSelectionArgs limits output to primary video/audio streams.
|
||||
func appendStreamSelectionArgs(args []string, audioTrackIndex int) []string {
|
||||
args = append(args, "-map", "0:v:0")
|
||||
|
||||
@@ -211,6 +211,42 @@ func TestBuildFFmpegArgs_MPEG4Part2DisablesHardwareDecode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts TranscodeOpts
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "hardware video transcode",
|
||||
opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
|
||||
want: "qsv",
|
||||
},
|
||||
{
|
||||
name: "copy video does not use hardware encode",
|
||||
opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "copy"},
|
||||
want: "none",
|
||||
},
|
||||
{
|
||||
name: "mpeg4 part 2 falls back to software",
|
||||
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
|
||||
want: "none",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := resolveEffectiveTranscodeHWAccel(tt.opts); got != tt.want {
|
||||
t.Fatalf("resolveEffectiveTranscodeHWAccel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
|
||||
@@ -25,6 +25,14 @@ type FileRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type RawMatchBacklogMode string
|
||||
|
||||
const (
|
||||
RawMatchBacklogGeneric RawMatchBacklogMode = "generic"
|
||||
RawMatchBacklogNonSeries RawMatchBacklogMode = "non_series"
|
||||
RawMatchBacklogMixed RawMatchBacklogMode = "mixed"
|
||||
)
|
||||
|
||||
// Pool returns the underlying connection pool (used by tests).
|
||||
func (r *FileRepository) Pool() *pgxpool.Pool { return r.pool }
|
||||
|
||||
@@ -819,6 +827,7 @@ func (r *FileRepository) Upsert(ctx context.Context, mf models.MediaFile) (*mode
|
||||
multi_episode_end = EXCLUDED.multi_episode_end,
|
||||
probe_source = EXCLUDED.probe_source,
|
||||
probe_updated_at = EXCLUDED.probe_updated_at,
|
||||
match_suppressed_at = NULL,
|
||||
missing_since = NULL,
|
||||
updated_at = NOW()
|
||||
RETURNING ` + fileColumns
|
||||
@@ -1285,6 +1294,7 @@ func (r *FileRepository) GetUnmatched(ctx context.Context, limit int) ([]*models
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
|
||||
LIMIT $1`
|
||||
@@ -1322,6 +1332,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
|
||||
LIMIT $1
|
||||
@@ -1352,6 +1363,7 @@ func (r *FileRepository) ClaimUnmatched(ctx context.Context, limit int) ([]*mode
|
||||
SET match_attempted_at = NOW()
|
||||
WHERE (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM representatives rep
|
||||
@@ -1391,6 +1403,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeries(ctx context.Context, limit int)
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')
|
||||
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
|
||||
@@ -1435,6 +1448,7 @@ func (r *FileRepository) ClaimUnmatchedMixed(ctx context.Context, limit int) ([]
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
@@ -1473,6 +1487,173 @@ func (r *FileRepository) MarkMatchAttempted(ctx context.Context, fileID int) err
|
||||
return err
|
||||
}
|
||||
|
||||
// IsMatchSuppressed reports whether a raw unmatched file has been cancelled
|
||||
// from background matching.
|
||||
func (r *FileRepository) IsMatchSuppressed(ctx context.Context, fileID int) (bool, error) {
|
||||
var suppressed bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT match_suppressed_at IS NOT NULL
|
||||
FROM media_files
|
||||
WHERE id = $1
|
||||
`, fileID).Scan(&suppressed)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("checking match suppression: %w", err)
|
||||
}
|
||||
return suppressed, nil
|
||||
}
|
||||
|
||||
// CountUnmatchedMatchBacklogByFolder counts raw unmatched files that the
|
||||
// background matcher can still claim for a library.
|
||||
func (r *FileRepository) CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) {
|
||||
var total int
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM media_files mf
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (
|
||||
$2 = 'generic'
|
||||
OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows'))
|
||||
OR (
|
||||
$2 = 'mixed'
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
)
|
||||
)
|
||||
`, folderID, string(normalizeRawMatchBacklogMode(mode))).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting unmatched match backlog: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ListUnmatchedMatchBacklogByFolder lists raw unmatched files that are still
|
||||
// eligible for the background matcher.
|
||||
func (r *FileRepository) ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
normalizedMode := normalizeRawMatchBacklogMode(mode)
|
||||
total, err := r.CountUnmatchedMatchBacklogByFolder(ctx, folderID, normalizedMode)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+mfFileColumns+`
|
||||
FROM media_files mf
|
||||
JOIN media_folders folders ON folders.id = mf.media_folder_id
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (
|
||||
$2 = 'generic'
|
||||
OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows'))
|
||||
OR (
|
||||
$2 = 'mixed'
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
)
|
||||
)
|
||||
ORDER BY mf.match_attempted_at ASC NULLS FIRST, mf.id ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`, folderID, string(normalizedMode), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("listing unmatched match backlog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
files, err := scanMediaFiles(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return files, total, nil
|
||||
}
|
||||
|
||||
// SuppressUnmatchedMatchBacklogByFolder prevents raw unmatched files in a
|
||||
// library from being claimed by the background matcher until they are retried
|
||||
// or seen by a new scan.
|
||||
func (r *FileRepository) SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) {
|
||||
normalizedMode := string(normalizeRawMatchBacklogMode(mode))
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE media_files mf
|
||||
SET match_suppressed_at = NOW(), updated_at = NOW()
|
||||
FROM media_folders folders
|
||||
WHERE folders.id = mf.media_folder_id
|
||||
AND mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (
|
||||
$2 = 'generic'
|
||||
OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows'))
|
||||
OR (
|
||||
$2 = 'mixed'
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
)
|
||||
)
|
||||
`, folderID, normalizedMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suppressing unmatched match backlog: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// RetryUnmatchedMatchBacklogByFolder re-enables raw unmatched files in a
|
||||
// library and moves them to the front of the background matcher order.
|
||||
func (r *FileRepository) RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode) (int, error) {
|
||||
normalizedMode := string(normalizeRawMatchBacklogMode(mode))
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE media_files mf
|
||||
SET match_suppressed_at = NULL,
|
||||
match_attempted_at = NULL,
|
||||
updated_at = NOW()
|
||||
FROM media_folders folders
|
||||
WHERE folders.id = mf.media_folder_id
|
||||
AND mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (
|
||||
$2 = 'generic'
|
||||
OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows'))
|
||||
OR (
|
||||
$2 = 'mixed'
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
)
|
||||
)
|
||||
`, folderID, normalizedMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("retrying unmatched match backlog: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
func normalizeRawMatchBacklogMode(mode RawMatchBacklogMode) RawMatchBacklogMode {
|
||||
switch mode {
|
||||
case RawMatchBacklogNonSeries, RawMatchBacklogMixed:
|
||||
return mode
|
||||
default:
|
||||
return RawMatchBacklogGeneric
|
||||
}
|
||||
}
|
||||
|
||||
// GetUnmatchedByFolderAndPathPrefix returns unmatched files for a single media
|
||||
// folder restricted to a subtree path.
|
||||
func (r *FileRepository) GetUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]*models.MediaFile, error) {
|
||||
@@ -1481,6 +1662,7 @@ func (r *FileRepository) GetUnmatchedByFolderAndPathPrefix(ctx context.Context,
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
|
||||
ORDER BY mf.id ASC`
|
||||
@@ -1535,6 +1717,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix(
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
|
||||
`)
|
||||
@@ -1575,6 +1758,7 @@ func (r *FileRepository) ClaimUnmatchedByFolderAndPathPrefix(
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM representatives rep
|
||||
@@ -1627,6 +1811,7 @@ func (r *FileRepository) ClaimUnmatchedNonSeriesByFolderAndPathPrefix(
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows')
|
||||
AND (mf.file_path = $2 OR mf.file_path LIKE $3 ESCAPE '\')
|
||||
@@ -1693,6 +1878,7 @@ func (r *FileRepository) ClaimUnmatchedMixedByFolderAndPathPrefix(
|
||||
WHERE mf.media_folder_id = $1
|
||||
AND (mf.content_id IS NULL OR mf.content_id = '')
|
||||
AND mf.missing_since IS NULL
|
||||
AND mf.match_suppressed_at IS NULL
|
||||
AND folders.enabled = true
|
||||
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
|
||||
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
|
||||
|
||||
@@ -222,6 +222,9 @@ func (s *Service) CancelByLibrary(ctx context.Context, libraryID int) (int, erro
|
||||
}
|
||||
if changed {
|
||||
cancelled++
|
||||
if cancelledRun, err := s.repo.GetByID(ctx, run.ID); err == nil {
|
||||
s.publish(ctx, "scan.cancelled", cancelledRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cancelled, nil
|
||||
|
||||
@@ -171,11 +171,12 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) {
|
||||
// rearmTriggers stops and restarts all triggers for a worker.
|
||||
func (m *TaskManager) rearmTriggers(w *taskWorker) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, tr := range w.triggers {
|
||||
tr.Stop()
|
||||
tr.Start(w.lastResult)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
w.notify()
|
||||
}
|
||||
|
||||
// Stop stops all triggers and cancels running tasks.
|
||||
|
||||
@@ -57,8 +57,16 @@ type fakeTrigger struct {
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func (t *fakeTrigger) Start(_ *taskmanager.ExecutionResult) {
|
||||
t.next = time.Now().Add(time.Minute)
|
||||
func (t *fakeTrigger) Start(lastResult *taskmanager.ExecutionResult) {
|
||||
interval := time.Minute
|
||||
if t.cfg.IntervalMs > 0 {
|
||||
interval = time.Duration(t.cfg.IntervalMs) * time.Millisecond
|
||||
}
|
||||
base := time.Now()
|
||||
if lastResult != nil && !lastResult.CompletedAt.IsZero() {
|
||||
base = lastResult.CompletedAt
|
||||
}
|
||||
t.next = base.Add(interval)
|
||||
}
|
||||
|
||||
func (t *fakeTrigger) Stop() {
|
||||
@@ -117,6 +125,26 @@ func newFakeTrigger(cfg taskmanager.TriggerConfig) taskmanager.Trigger {
|
||||
}
|
||||
}
|
||||
|
||||
type recordingObserver struct {
|
||||
mu sync.Mutex
|
||||
updates []taskmanager.TaskInfo
|
||||
}
|
||||
|
||||
func (o *recordingObserver) TaskUpdated(info taskmanager.TaskInfo) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.updates = append(o.updates, info)
|
||||
}
|
||||
|
||||
func (o *recordingObserver) last() taskmanager.TaskInfo {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if len(o.updates) == 0 {
|
||||
return taskmanager.TaskInfo{}
|
||||
}
|
||||
return o.updates[len(o.updates)-1]
|
||||
}
|
||||
|
||||
func TestTaskManagerStartSeedsCleanupTaskDefaults(t *testing.T) {
|
||||
triggerRepo := &fakeTriggerRepository{triggers: map[string][]taskmanager.TriggerConfig{}}
|
||||
settings := &fakeServerSettings{
|
||||
@@ -191,3 +219,49 @@ func TestTaskManagerStartPreservesExistingTriggers(t *testing.T) {
|
||||
t.Fatalf("worker triggers = %#v, want %#v", got, existing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskManagerRunTaskNotifiesAfterTriggerRearm(t *testing.T) {
|
||||
const taskKey = "refresh_metadata"
|
||||
triggerRepo := &fakeTriggerRepository{
|
||||
triggers: map[string][]taskmanager.TriggerConfig{
|
||||
taskKey: {
|
||||
{Type: taskmanager.TriggerTypeInterval, IntervalMs: int64(time.Hour / time.Millisecond)},
|
||||
},
|
||||
},
|
||||
}
|
||||
observer := &recordingObserver{}
|
||||
manager := taskmanager.New(
|
||||
triggerRepo,
|
||||
fakeExecutionRepository{},
|
||||
newFakeTrigger,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
manager.AddObserver(observer)
|
||||
manager.Register(stubTask{key: taskKey})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer manager.Stop()
|
||||
defer cancel()
|
||||
manager.Start(ctx)
|
||||
|
||||
if err := manager.RunTask(ctx, taskKey); err != nil {
|
||||
t.Fatalf("RunTask() error = %v", err)
|
||||
}
|
||||
|
||||
last := observer.last()
|
||||
if last.LastExecution == nil {
|
||||
t.Fatal("last notification missing execution result")
|
||||
}
|
||||
if last.NextRunAt == nil {
|
||||
t.Fatal("last notification missing next run time")
|
||||
}
|
||||
if !last.NextRunAt.After(last.LastExecution.CompletedAt) {
|
||||
t.Fatalf("next run = %s, want after completed_at %s",
|
||||
last.NextRunAt.Format(time.RFC3339Nano),
|
||||
last.LastExecution.CompletedAt.Format(time.RFC3339Nano))
|
||||
}
|
||||
if last.NextRunAt.Sub(last.LastExecution.CompletedAt) < 59*time.Minute {
|
||||
t.Fatalf("next run was not rearmed from the latest completion: got %s after completion",
|
||||
last.NextRunAt.Sub(last.LastExecution.CompletedAt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (t *MatchMediaTask) Description() string {
|
||||
return "Matches unmatched media files to metadata from providers"
|
||||
}
|
||||
func (t *MatchMediaTask) Category() taskmanager.TaskCategory { return taskmanager.TaskCategoryMetadata }
|
||||
func (t *MatchMediaTask) IsHidden() bool { return true }
|
||||
func (t *MatchMediaTask) IsHidden() bool { return false }
|
||||
|
||||
func (t *MatchMediaTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
||||
return []taskmanager.TriggerConfig{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeBatchMatcher struct {
|
||||
processed int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBatchMatcher) ProcessBatch(context.Context) (int, error) {
|
||||
return f.processed, f.err
|
||||
}
|
||||
|
||||
func TestMatchMediaTaskIsVisible(t *testing.T) {
|
||||
task := NewMatchMediaTask(&fakeBatchMatcher{})
|
||||
|
||||
if task.IsHidden() {
|
||||
t.Fatal("IsHidden() = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const startupDelay = 5 * time.Second
|
||||
type StartupTrigger struct {
|
||||
cfg taskmanager.TriggerConfig
|
||||
ch chan struct{}
|
||||
delay time.Duration
|
||||
nextRun time.Time
|
||||
timer *time.Timer
|
||||
stopCh chan struct{}
|
||||
@@ -23,8 +24,9 @@ type StartupTrigger struct {
|
||||
|
||||
func NewStartupTrigger(cfg taskmanager.TriggerConfig) *StartupTrigger {
|
||||
return &StartupTrigger{
|
||||
cfg: cfg,
|
||||
ch: make(chan struct{}, 1),
|
||||
cfg: cfg,
|
||||
ch: make(chan struct{}, 1),
|
||||
delay: startupDelay,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +41,14 @@ func (s *StartupTrigger) Start(_ *taskmanager.ExecutionResult) {
|
||||
}
|
||||
|
||||
if s.fired {
|
||||
s.nextRun = time.Time{}
|
||||
return
|
||||
}
|
||||
s.fired = true
|
||||
|
||||
s.stopCh = make(chan struct{})
|
||||
s.nextRun = time.Now().Add(startupDelay)
|
||||
s.timer = time.NewTimer(startupDelay)
|
||||
s.nextRun = time.Now().Add(s.delay)
|
||||
s.timer = time.NewTimer(s.delay)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
@@ -58,6 +61,9 @@ func (s *StartupTrigger) Start(_ *taskmanager.ExecutionResult) {
|
||||
}
|
||||
return
|
||||
case <-s.timer.C:
|
||||
s.mu.Lock()
|
||||
s.nextRun = time.Time{}
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.ch <- struct{}{}:
|
||||
default:
|
||||
@@ -76,6 +82,7 @@ func (s *StartupTrigger) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
s.nextRun = time.Time{}
|
||||
}
|
||||
|
||||
func (s *StartupTrigger) NextRunTime() time.Time {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package triggers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
)
|
||||
|
||||
func TestStartupTriggerClearsNextRunAfterFire(t *testing.T) {
|
||||
trigger := NewStartupTrigger(taskmanager.TriggerConfig{Type: taskmanager.TriggerTypeStartup})
|
||||
trigger.delay = time.Millisecond
|
||||
|
||||
trigger.Start(nil)
|
||||
if trigger.NextRunTime().IsZero() {
|
||||
t.Fatal("expected startup trigger to report next run before firing")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-trigger.C():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("startup trigger did not fire")
|
||||
}
|
||||
|
||||
if next := trigger.NextRunTime(); !next.IsZero() {
|
||||
t.Fatalf("next run after fire = %s, want zero", next.Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
trigger.Start(nil)
|
||||
if next := trigger.NextRunTime(); !next.IsZero() {
|
||||
t.Fatalf("next run after rearm = %s, want zero", next.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupTriggerClearsNextRunWhenStopped(t *testing.T) {
|
||||
trigger := NewStartupTrigger(taskmanager.TriggerConfig{Type: taskmanager.TriggerTypeStartup})
|
||||
trigger.delay = time.Second
|
||||
|
||||
trigger.Start(nil)
|
||||
if trigger.NextRunTime().IsZero() {
|
||||
t.Fatal("expected startup trigger to report next run before stop")
|
||||
}
|
||||
|
||||
trigger.Stop()
|
||||
if next := trigger.NextRunTime(); !next.IsZero() {
|
||||
t.Fatalf("next run after stop = %s, want zero", next.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type TranscodeStartRequest struct {
|
||||
type TranscodeStartResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
HWAccel string `json:"hw_accel,omitempty"`
|
||||
}
|
||||
|
||||
// HealthResponse is the JSON response for GET /api/v1/health.
|
||||
@@ -238,6 +239,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
s.activeJobs.Add(1)
|
||||
|
||||
// Track session in Redis
|
||||
effectiveHWAccel := session.Opts().HWAccel
|
||||
s.tracker.Track(r.Context(), nodesessions.SessionInfo{
|
||||
SessionID: req.SessionID,
|
||||
NodeURL: s.tracker.NodeURL(),
|
||||
@@ -246,7 +248,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
CodecVideo: req.TargetCodecVideo,
|
||||
CodecAudio: req.TargetCodecAudio,
|
||||
Resolution: req.TargetResolution,
|
||||
HWAccel: opts.HWAccel,
|
||||
HWAccel: effectiveHWAccel,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
@@ -254,6 +256,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(TranscodeStartResponse{
|
||||
SessionID: req.SessionID,
|
||||
Status: "started",
|
||||
HWAccel: effectiveHWAccel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,15 @@ CREATE TABLE IF NOT EXISTS user_device_settings (
|
||||
PRIMARY KEY (profile_id, device_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_devices (
|
||||
profile_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
device_platform TEXT NOT NULL DEFAULT '',
|
||||
last_seen_at TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
@@ -275,7 +284,10 @@ func InitSchema(db *sql.DB) error {
|
||||
if err := ensureWatchHistoryIdentityColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migratePlaybackSettingsToDeviceScope(db)
|
||||
if err := migratePlaybackSettingsToDeviceScope(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillUserDevices(db)
|
||||
}
|
||||
|
||||
func ensureAutoSkipRecapPreviewColumns(db *sql.DB) error {
|
||||
@@ -427,6 +439,40 @@ func ensureDeviceSettingsProfileColumn(db *sql.DB) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func backfillUserDevices(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO user_devices (
|
||||
profile_id, device_id, device_name, device_platform, last_seen_at
|
||||
)
|
||||
SELECT
|
||||
profile_id,
|
||||
device_id,
|
||||
COALESCE(MAX(device_name), ''),
|
||||
COALESCE(MAX(device_platform), ''),
|
||||
MAX(updated_at)
|
||||
FROM user_device_settings
|
||||
WHERE TRIM(device_id) <> ''
|
||||
GROUP BY profile_id, device_id
|
||||
ON CONFLICT(profile_id, device_id) DO UPDATE SET
|
||||
device_name = CASE
|
||||
WHEN excluded.device_name <> '' THEN excluded.device_name
|
||||
ELSE user_devices.device_name
|
||||
END,
|
||||
device_platform = CASE
|
||||
WHEN excluded.device_platform <> '' THEN excluded.device_platform
|
||||
ELSE user_devices.device_platform
|
||||
END,
|
||||
last_seen_at = CASE
|
||||
WHEN excluded.last_seen_at > user_devices.last_seen_at THEN excluded.last_seen_at
|
||||
ELSE user_devices.last_seen_at
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backfilling user_devices: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureProfileSectionOverridesRemovedColumn(db *sql.DB) error {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
|
||||
@@ -3,6 +3,7 @@ package userdb
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
@@ -81,7 +82,64 @@ func GetDeviceSetting(db *sql.DB, profileID, deviceID, key string) (*userstore.D
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func RegisterDevice(db *sql.DB, entry userstore.DeviceEntry) error {
|
||||
if strings.TrimSpace(entry.ProfileID) == "" || strings.TrimSpace(entry.DeviceID) == "" {
|
||||
return nil
|
||||
}
|
||||
lastSeenAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO user_devices
|
||||
(profile_id, device_id, device_name, device_platform, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id, device_id) DO UPDATE SET
|
||||
device_name = CASE
|
||||
WHEN excluded.device_name <> '' THEN excluded.device_name
|
||||
ELSE user_devices.device_name
|
||||
END,
|
||||
device_platform = CASE
|
||||
WHEN excluded.device_platform <> '' THEN excluded.device_platform
|
||||
ELSE user_devices.device_platform
|
||||
END,
|
||||
last_seen_at = excluded.last_seen_at`,
|
||||
entry.ProfileID, entry.DeviceID, entry.DeviceName, entry.DevicePlatform, lastSeenAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registering device %q: %w", entry.DeviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListDevices(db *sql.DB) ([]userstore.DeviceEntry, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT profile_id, device_id, device_name, device_platform, last_seen_at
|
||||
FROM user_devices
|
||||
ORDER BY last_seen_at DESC, profile_id ASC, device_name ASC, device_id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []userstore.DeviceEntry
|
||||
for rows.Next() {
|
||||
var entry userstore.DeviceEntry
|
||||
if err := rows.Scan(&entry.ProfileID, &entry.DeviceID, &entry.DeviceName, &entry.DevicePlatform, &entry.LastSeenAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning device: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func SetDeviceSetting(db *sql.DB, entry userstore.DeviceSettingEntry) error {
|
||||
if err := RegisterDevice(db, userstore.DeviceEntry{
|
||||
ProfileID: entry.ProfileID,
|
||||
DeviceID: entry.DeviceID,
|
||||
DeviceName: entry.DeviceName,
|
||||
DevicePlatform: entry.DevicePlatform,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
updatedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO user_device_settings
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewSQLiteUserStore(db *sql.DB) *SQLiteUserStore {
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ userstore.UserStore = (*SQLiteUserStore)(nil)
|
||||
var _ userstore.DeviceRegistry = (*SQLiteUserStore)(nil)
|
||||
|
||||
// --- Profiles ---
|
||||
|
||||
@@ -291,6 +292,14 @@ func (s *SQLiteUserStore) GetDeviceSetting(_ context.Context, profileID, deviceI
|
||||
return GetDeviceSetting(s.db, profileID, deviceID, key)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) RegisterDevice(_ context.Context, entry userstore.DeviceEntry) error {
|
||||
return RegisterDevice(s.db, entry)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) ListDevices(_ context.Context) ([]userstore.DeviceEntry, error) {
|
||||
return ListDevices(s.db)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) SetDeviceSetting(_ context.Context, entry userstore.DeviceSettingEntry) error {
|
||||
return SetDeviceSetting(s.db, entry)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type PostgresUserStore struct {
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ userstore.UserStore = (*PostgresUserStore)(nil)
|
||||
var _ userstore.DeviceRegistry = (*PostgresUserStore)(nil)
|
||||
|
||||
// newStore creates a PostgresUserStore scoped to a user.
|
||||
func newStore(pool *pgxpool.Pool, userID int) *PostgresUserStore {
|
||||
|
||||
@@ -3,6 +3,7 @@ package pgstore
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -85,7 +86,65 @@ func (s *PostgresUserStore) GetDeviceSetting(ctx context.Context, profileID, dev
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (s *PostgresUserStore) RegisterDevice(ctx context.Context, entry userstore.DeviceEntry) error {
|
||||
if strings.TrimSpace(entry.ProfileID) == "" || strings.TrimSpace(entry.DeviceID) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO user_devices
|
||||
(user_id, profile_id, device_id, device_name, device_platform, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT(user_id, profile_id, device_id) DO UPDATE SET
|
||||
device_name = CASE
|
||||
WHEN excluded.device_name <> '' THEN excluded.device_name
|
||||
ELSE user_devices.device_name
|
||||
END,
|
||||
device_platform = CASE
|
||||
WHEN excluded.device_platform <> '' THEN excluded.device_platform
|
||||
ELSE user_devices.device_platform
|
||||
END,
|
||||
last_seen_at = NOW()`,
|
||||
s.userID, entry.ProfileID, entry.DeviceID, entry.DeviceName, entry.DevicePlatform,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registering device %q: %w", entry.DeviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresUserStore) ListDevices(ctx context.Context) ([]userstore.DeviceEntry, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT profile_id, device_id, device_name, device_platform, last_seen_at::text
|
||||
FROM user_devices
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_seen_at DESC, profile_id ASC, device_name ASC, device_id ASC`,
|
||||
s.userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []userstore.DeviceEntry
|
||||
for rows.Next() {
|
||||
var entry userstore.DeviceEntry
|
||||
if err := rows.Scan(&entry.ProfileID, &entry.DeviceID, &entry.DeviceName, &entry.DevicePlatform, &entry.LastSeenAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning device: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PostgresUserStore) SetDeviceSetting(ctx context.Context, entry userstore.DeviceSettingEntry) error {
|
||||
if err := s.RegisterDevice(ctx, userstore.DeviceEntry{
|
||||
ProfileID: entry.ProfileID,
|
||||
DeviceID: entry.DeviceID,
|
||||
DeviceName: entry.DeviceName,
|
||||
DevicePlatform: entry.DevicePlatform,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO user_device_settings
|
||||
(user_id, profile_id, device_id, key, value, device_name, device_platform, updated_at)
|
||||
|
||||
@@ -108,3 +108,10 @@ type UserStore interface {
|
||||
UpsertLibraryPlaybackPreference(ctx context.Context, pref LibraryPlaybackPreference) error
|
||||
DeleteLibraryPlaybackPreference(ctx context.Context, profileID string, libraryID int) error
|
||||
}
|
||||
|
||||
// DeviceRegistry is implemented by stores that track observed devices even
|
||||
// when they do not currently have any device-scoped overrides.
|
||||
type DeviceRegistry interface {
|
||||
RegisterDevice(ctx context.Context, entry DeviceEntry) error
|
||||
ListDevices(ctx context.Context) ([]DeviceEntry, error)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ type DeviceSettingEntry struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// DeviceEntry is a durable app/device identity observed for one profile
|
||||
// within a user. It lets admins configure a device before it has overrides.
|
||||
type DeviceEntry struct {
|
||||
ProfileID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
DevicePlatform string
|
||||
LastSeenAt string
|
||||
}
|
||||
|
||||
// UpdateProfileInput holds optional fields for updating a profile.
|
||||
type UpdateProfileInput struct {
|
||||
Name *string
|
||||
|
||||
@@ -34,8 +34,10 @@ type SessionSync struct {
|
||||
TargetVideoCodec string
|
||||
TargetAudioCodec string
|
||||
TargetBitrateKbps int
|
||||
TranscodeHWAccel string
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PositionSeconds float64
|
||||
IsPaused bool
|
||||
HasWebSocket bool
|
||||
}
|
||||
@@ -138,8 +140,8 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
|
||||
reporting_node, started_at, updated_at, last_sync_at, client_ip,
|
||||
audio_track_index, transcode_audio, stream_bitrate_kbps, transcode_node_url,
|
||||
target_resolution, target_video_codec, target_audio_codec, target_bitrate_kbps,
|
||||
is_paused, has_websocket)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
transcode_hw_accel, position_seconds, is_paused, has_websocket)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10::inet, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
@@ -158,6 +160,8 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
|
||||
target_video_codec = EXCLUDED.target_video_codec,
|
||||
target_audio_codec = EXCLUDED.target_audio_codec,
|
||||
target_bitrate_kbps = EXCLUDED.target_bitrate_kbps,
|
||||
transcode_hw_accel = EXCLUDED.transcode_hw_accel,
|
||||
position_seconds = EXCLUDED.position_seconds,
|
||||
is_paused = EXCLUDED.is_paused,
|
||||
has_websocket = EXCLUDED.has_websocket,
|
||||
last_sync_at = NOW()
|
||||
@@ -166,6 +170,7 @@ func (r *Reconciler) ReconcileNodeSessions(ctx context.Context, reportingNode st
|
||||
s.TranscodeAudio, nullableInt(s.StreamBitrateKbps), nullableString(s.TranscodeNodeURL),
|
||||
nullableString(s.TargetResolution), nullableString(s.TargetVideoCodec),
|
||||
nullableString(s.TargetAudioCodec), nullableInt(s.TargetBitrateKbps),
|
||||
nullableString(s.TranscodeHWAccel), normalizePositionSeconds(s.PositionSeconds),
|
||||
s.IsPaused, s.HasWebSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upserting session %s: %w", s.SessionID, err)
|
||||
@@ -233,8 +238,10 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri
|
||||
COALESCE(target_video_codec, ''),
|
||||
COALESCE(target_audio_codec, ''),
|
||||
COALESCE(target_bitrate_kbps, 0),
|
||||
COALESCE(transcode_hw_accel, ''),
|
||||
started_at,
|
||||
updated_at,
|
||||
COALESCE(position_seconds, 0),
|
||||
COALESCE(is_paused, FALSE),
|
||||
COALESCE(has_websocket, FALSE)
|
||||
FROM playback_sessions_sync
|
||||
@@ -266,8 +273,10 @@ func loadNodeSessionsSnapshot(ctx context.Context, tx pgx.Tx, reportingNode stri
|
||||
&s.TargetVideoCodec,
|
||||
&s.TargetAudioCodec,
|
||||
&s.TargetBitrateKbps,
|
||||
&s.TranscodeHWAccel,
|
||||
&s.StartedAt,
|
||||
&s.UpdatedAt,
|
||||
&s.PositionSeconds,
|
||||
&s.IsPaused,
|
||||
&s.HasWebSocket,
|
||||
); err != nil {
|
||||
@@ -317,8 +326,10 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool {
|
||||
left[i].TargetVideoCodec != right[i].TargetVideoCodec ||
|
||||
left[i].TargetAudioCodec != right[i].TargetAudioCodec ||
|
||||
left[i].TargetBitrateKbps != right[i].TargetBitrateKbps ||
|
||||
left[i].TranscodeHWAccel != right[i].TranscodeHWAccel ||
|
||||
!left[i].StartedAt.Equal(right[i].StartedAt) ||
|
||||
!left[i].UpdatedAt.Equal(right[i].UpdatedAt) ||
|
||||
normalizePositionSeconds(left[i].PositionSeconds) != normalizePositionSeconds(right[i].PositionSeconds) ||
|
||||
left[i].IsPaused != right[i].IsPaused ||
|
||||
left[i].HasWebSocket != right[i].HasWebSocket {
|
||||
return false
|
||||
@@ -327,6 +338,13 @@ func sessionSnapshotsEqual(left, right []SessionSync) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePositionSeconds(position float64) float64 {
|
||||
if position < 0 {
|
||||
return 0
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
func nullableIP(ip string) any {
|
||||
if strings.TrimSpace(ip) == "" {
|
||||
return nil
|
||||
|
||||
@@ -90,7 +90,7 @@ CREATE TABLE public.admin_jobs (
|
||||
heartbeat_at timestamp with time zone,
|
||||
expires_at timestamp with time zone,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT admin_jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'completed'::text, 'failed'::text])))
|
||||
CONSTRAINT admin_jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'completed'::text, 'failed'::text, 'cancelled'::text])))
|
||||
);
|
||||
|
||||
|
||||
@@ -945,6 +945,7 @@ CREATE TABLE public.user_settings (
|
||||
|
||||
CREATE TABLE public.user_device_settings (
|
||||
user_id integer NOT NULL,
|
||||
profile_id text NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
key text NOT NULL,
|
||||
value text NOT NULL,
|
||||
@@ -953,6 +954,15 @@ CREATE TABLE public.user_device_settings (
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.user_devices (
|
||||
user_id integer NOT NULL,
|
||||
profile_id text NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
device_name text DEFAULT ''::text NOT NULL,
|
||||
device_platform text DEFAULT ''::text NOT NULL,
|
||||
last_seen_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_subtitle_preferences; Type: TABLE; Schema: public; Owner: -
|
||||
@@ -1578,7 +1588,10 @@ ALTER TABLE ONLY public.user_settings
|
||||
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id, key);
|
||||
|
||||
ALTER TABLE ONLY public.user_device_settings
|
||||
ADD CONSTRAINT user_device_settings_pkey PRIMARY KEY (user_id, device_id, key);
|
||||
ADD CONSTRAINT user_device_settings_pkey PRIMARY KEY (user_id, profile_id, device_id, key);
|
||||
|
||||
ALTER TABLE ONLY public.user_devices
|
||||
ADD CONSTRAINT user_devices_pkey PRIMARY KEY (user_id, profile_id, device_id);
|
||||
|
||||
|
||||
--
|
||||
@@ -2017,6 +2030,13 @@ CREATE INDEX idx_user_collections_profile ON public.user_personal_collections US
|
||||
CREATE INDEX idx_user_favorites_profile ON public.user_favorites USING btree (user_id, profile_id, added_at DESC);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_user_device_settings_user_key; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_user_device_settings_user_key ON public.user_device_settings USING btree (user_id, key);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_user_profile_allowed_libraries_lookup; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -2348,6 +2368,15 @@ ALTER TABLE ONLY public.user_settings
|
||||
ALTER TABLE ONLY public.user_device_settings
|
||||
ADD CONSTRAINT user_device_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.user_device_settings
|
||||
ADD CONSTRAINT user_device_settings_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.user_devices
|
||||
ADD CONSTRAINT user_devices_profile_fkey FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.user_devices
|
||||
ADD CONSTRAINT user_devices_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: user_subtitle_preferences user_subtitle_preferences_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE playback_sessions_sync
|
||||
DROP COLUMN IF EXISTS position_seconds;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE playback_sessions_sync
|
||||
ADD COLUMN IF NOT EXISTS position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE playback_sessions_sync
|
||||
DROP COLUMN IF EXISTS transcode_hw_accel;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE playback_sessions_sync
|
||||
ADD COLUMN IF NOT EXISTS transcode_hw_accel text;
|
||||
@@ -0,0 +1,23 @@
|
||||
UPDATE public.admin_jobs
|
||||
SET status = 'failed',
|
||||
message = CASE
|
||||
WHEN message = '' THEN 'Admin job cancelled'
|
||||
ELSE message
|
||||
END,
|
||||
error_message = CASE
|
||||
WHEN error_message = '' THEN 'Cancelled status removed by rollback'
|
||||
ELSE error_message
|
||||
END
|
||||
WHERE status = 'cancelled';
|
||||
|
||||
ALTER TABLE public.admin_jobs
|
||||
DROP CONSTRAINT IF EXISTS admin_jobs_status_check;
|
||||
|
||||
ALTER TABLE public.admin_jobs
|
||||
ADD CONSTRAINT admin_jobs_status_check
|
||||
CHECK ((status = ANY (ARRAY[
|
||||
'queued'::text,
|
||||
'running'::text,
|
||||
'completed'::text,
|
||||
'failed'::text
|
||||
])));
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE public.admin_jobs
|
||||
DROP CONSTRAINT IF EXISTS admin_jobs_status_check;
|
||||
|
||||
ALTER TABLE public.admin_jobs
|
||||
ADD CONSTRAINT admin_jobs_status_check
|
||||
CHECK ((status = ANY (ARRAY[
|
||||
'queued'::text,
|
||||
'running'::text,
|
||||
'completed'::text,
|
||||
'failed'::text,
|
||||
'cancelled'::text
|
||||
])));
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_media_files_unmatched_match_ready;
|
||||
|
||||
ALTER TABLE media_files
|
||||
DROP COLUMN IF EXISTS match_suppressed_at;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE media_files
|
||||
ADD COLUMN IF NOT EXISTS match_suppressed_at timestamp with time zone;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_files_unmatched_match_ready
|
||||
ON media_files (media_folder_id, match_suppressed_at, match_attempted_at ASC NULLS FIRST, id ASC)
|
||||
WHERE (content_id IS NULL OR content_id = '')
|
||||
AND missing_since IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS public.user_devices;
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS public.user_devices (
|
||||
user_id integer NOT NULL,
|
||||
profile_id text NOT NULL,
|
||||
device_id text NOT NULL,
|
||||
device_name text NOT NULL DEFAULT '',
|
||||
device_platform text NOT NULL DEFAULT '',
|
||||
last_seen_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT user_devices_pkey PRIMARY KEY (user_id, profile_id, device_id),
|
||||
CONSTRAINT user_devices_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_devices_profile_fkey
|
||||
FOREIGN KEY (user_id, profile_id) REFERENCES public.user_profiles(user_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO public.user_devices (
|
||||
user_id,
|
||||
profile_id,
|
||||
device_id,
|
||||
device_name,
|
||||
device_platform,
|
||||
last_seen_at
|
||||
)
|
||||
SELECT DISTINCT ON (user_id, profile_id, device_id)
|
||||
user_id,
|
||||
profile_id,
|
||||
device_id,
|
||||
COALESCE(device_name, ''),
|
||||
COALESCE(device_platform, ''),
|
||||
updated_at
|
||||
FROM (
|
||||
SELECT
|
||||
settings.user_id,
|
||||
COALESCE(valid_profiles.id, fallback_profiles.id) AS profile_id,
|
||||
settings.device_id,
|
||||
settings.device_name,
|
||||
settings.device_platform,
|
||||
settings.updated_at
|
||||
FROM public.user_device_settings AS settings
|
||||
LEFT JOIN public.user_profiles AS valid_profiles
|
||||
ON valid_profiles.user_id = settings.user_id
|
||||
AND valid_profiles.id = settings.profile_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT profiles.id
|
||||
FROM public.user_profiles AS profiles
|
||||
WHERE profiles.user_id = settings.user_id
|
||||
ORDER BY profiles.is_primary DESC, profiles.created_at ASC, profiles.id ASC
|
||||
LIMIT 1
|
||||
) AS fallback_profiles ON true
|
||||
WHERE settings.device_id <> ''
|
||||
) AS resolved_settings
|
||||
WHERE profile_id IS NOT NULL
|
||||
ORDER BY user_id, profile_id, device_id, updated_at DESC
|
||||
ON CONFLICT (user_id, profile_id, device_id) DO UPDATE SET
|
||||
device_name = CASE
|
||||
WHEN excluded.device_name <> '' THEN excluded.device_name
|
||||
ELSE user_devices.device_name
|
||||
END,
|
||||
device_platform = CASE
|
||||
WHEN excluded.device_platform <> '' THEN excluded.device_platform
|
||||
ELSE user_devices.device_platform
|
||||
END,
|
||||
last_seen_at = GREATEST(user_devices.last_seen_at, excluded.last_seen_at);
|
||||
+30
-8
@@ -18,6 +18,8 @@ import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import ImpersonationBanner from "@/components/ImpersonationBanner";
|
||||
import { loadStoredImpersonationAdminSession } from "@/lib/impersonationSession";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import Layout from "@/components/Layout";
|
||||
import AdminLayout from "@/components/AdminLayout";
|
||||
import Home from "@/pages/Home";
|
||||
@@ -537,6 +539,23 @@ function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function RealtimeEventChannels() {
|
||||
const { user } = useAuth();
|
||||
|
||||
useEventChannel("catalog");
|
||||
useEventChannel("user_state");
|
||||
|
||||
return user?.role === "admin" ? <AdminRealtimeEventChannels /> : null;
|
||||
}
|
||||
|
||||
function AdminRealtimeEventChannels() {
|
||||
useEventChannel("jobs");
|
||||
useEventChannel("sessions");
|
||||
useEventChannel("tasks");
|
||||
useEventChannel("scans");
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -546,14 +565,17 @@ export default function App() {
|
||||
<ThemeProvider>
|
||||
<CustomThemeProvider>
|
||||
<WatchPlaybackProvider>
|
||||
<ScrollRestorationManager />
|
||||
<RouteAnnouncer />
|
||||
<QueryCacheManager />
|
||||
<AppChrome />
|
||||
<AppRoutes />
|
||||
<WatchPlaybackHost />
|
||||
<WatchPlaybackBar />
|
||||
<Toaster />
|
||||
<RealtimeEventsProvider>
|
||||
<RealtimeEventChannels />
|
||||
<ScrollRestorationManager />
|
||||
<RouteAnnouncer />
|
||||
<QueryCacheManager />
|
||||
<AppChrome />
|
||||
<AppRoutes />
|
||||
<WatchPlaybackHost />
|
||||
<WatchPlaybackBar />
|
||||
<Toaster />
|
||||
</RealtimeEventsProvider>
|
||||
</WatchPlaybackProvider>
|
||||
</CustomThemeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+66
-1
@@ -1972,6 +1972,7 @@ export interface AdminSession {
|
||||
file_duration: number | null;
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
position_seconds: number;
|
||||
is_paused: boolean;
|
||||
has_playback_control?: boolean;
|
||||
client_ip?: string;
|
||||
@@ -1982,6 +1983,7 @@ export interface AdminSession {
|
||||
target_video_codec?: string;
|
||||
target_audio_codec?: string;
|
||||
target_bitrate_kbps: number | null;
|
||||
transcode_hw_accel?: string;
|
||||
source_container?: string;
|
||||
source_bitrate_kbps: number | null;
|
||||
source_video_codec?: string;
|
||||
@@ -2186,6 +2188,9 @@ export interface AdminDeviceDetail {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
device_platform: string;
|
||||
override_count: number;
|
||||
profile_count: number;
|
||||
profiles: AdminDeviceProfileSummary[];
|
||||
last_updated: string;
|
||||
settings: {
|
||||
user_id: number;
|
||||
@@ -2250,6 +2255,66 @@ export interface LibraryMountCheckResponse {
|
||||
roots: LibraryMountCheckRoot[];
|
||||
}
|
||||
|
||||
export interface LibraryMetadataMatchQueueStatus {
|
||||
library_id: number;
|
||||
movie_count: number;
|
||||
series_count: number;
|
||||
raw_file_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface LibraryMovieMatchQueueEntry {
|
||||
media_file_id: number;
|
||||
media_folder_id: number;
|
||||
file_path: string;
|
||||
first_queued_at: string;
|
||||
available_at: string;
|
||||
last_attempted_at?: string;
|
||||
attempt_count: number;
|
||||
last_error?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LibrarySeriesMatchQueueEntry {
|
||||
media_folder_id: number;
|
||||
observed_root_path: string;
|
||||
first_queued_at: string;
|
||||
available_at: string;
|
||||
last_attempted_at?: string;
|
||||
attempt_count: number;
|
||||
last_error?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LibraryRawMatchBacklogEntry {
|
||||
media_file_id: number;
|
||||
media_folder_id: number;
|
||||
file_path: string;
|
||||
base_title?: string;
|
||||
base_year?: number;
|
||||
base_type?: string;
|
||||
last_attempted_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LibraryMetadataMatchQueueDetail extends LibraryMetadataMatchQueueStatus {
|
||||
movies: LibraryMovieMatchQueueEntry[];
|
||||
series: LibrarySeriesMatchQueueEntry[];
|
||||
raw_files: LibraryRawMatchBacklogEntry[];
|
||||
}
|
||||
|
||||
export interface LibraryMetadataMatchQueueActionResponse {
|
||||
status: "queued" | "cancelled";
|
||||
library_id: number;
|
||||
movie_cancelled?: number;
|
||||
series_cancelled?: number;
|
||||
raw_file_cancelled?: number;
|
||||
raw_file_retried?: number;
|
||||
total_cancelled?: number;
|
||||
queue: LibraryMetadataMatchQueueStatus;
|
||||
}
|
||||
|
||||
export interface LibrarySkippedRoot {
|
||||
library_id: number;
|
||||
library_name: string;
|
||||
@@ -2402,7 +2467,7 @@ export interface CatalogSeedImportResponse {
|
||||
unmatched_roots?: string[];
|
||||
}
|
||||
|
||||
export type AdminJobStatus = "queued" | "running" | "completed" | "failed";
|
||||
export type AdminJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export interface LibraryRefreshJobRequest {
|
||||
library_id: number;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import AdminSidebar from "@/components/AdminSidebar";
|
||||
import ServerActivity from "@/components/ServerActivity";
|
||||
import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { resolveAdminDocumentTitle } from "@/lib/documentTitle";
|
||||
@@ -18,77 +16,64 @@ export default function AdminLayout() {
|
||||
useDocumentTitle(resolveAdminDocumentTitle(location.pathname));
|
||||
|
||||
return (
|
||||
<RealtimeEventsProvider>
|
||||
<AdminLayoutEventChannels />
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-hidden">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<div className="from-primary/6 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile header */}
|
||||
<div className="glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 lg:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all"
|
||||
aria-label="Open admin menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-primary border-border/70 bg-surface flex h-9 w-9 items-center justify-center rounded-xl border text-sm font-bold">
|
||||
▶
|
||||
</div>
|
||||
<span className="text-[15px] font-extrabold tracking-tight">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServerActivity />
|
||||
</div>
|
||||
|
||||
{/* Desktop activity indicator */}
|
||||
<div className="fixed top-5 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[260px] p-0 sm:max-w-[260px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Admin Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className={`relative z-10 min-h-screen px-4 py-4 sm:px-6 lg:ml-[240px] lg:px-8 lg:py-8 xl:px-10 ${
|
||||
isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="admin-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-hidden">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<div className="from-primary/6 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
</RealtimeEventsProvider>
|
||||
|
||||
{/* Mobile header */}
|
||||
<div className="glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 lg:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all"
|
||||
aria-label="Open admin menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-primary border-border/70 bg-surface flex h-9 w-9 items-center justify-center rounded-xl border text-sm font-bold">
|
||||
▶
|
||||
</div>
|
||||
<span className="text-[15px] font-extrabold tracking-tight">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServerActivity />
|
||||
</div>
|
||||
|
||||
{/* Desktop activity indicator */}
|
||||
<div className="fixed top-5 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[260px] p-0 sm:max-w-[260px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Admin Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className={`relative z-10 min-h-screen px-4 py-4 sm:px-6 lg:ml-[240px] lg:px-8 lg:py-8 xl:px-10 ${
|
||||
isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="admin-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminLayoutEventChannels() {
|
||||
useEventChannel("catalog");
|
||||
useEventChannel("user_state");
|
||||
useEventChannel("jobs");
|
||||
useEventChannel("sessions");
|
||||
useEventChannel("tasks");
|
||||
useEventChannel("scans");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MoreHorizontal, MessageSquare, Pause, Play, Square, OctagonAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -27,6 +28,10 @@ type SessionActionKind = "pause" | "resume" | "stop" | "terminate" | "message";
|
||||
interface AdminSessionActionsProps {
|
||||
session: AdminSession;
|
||||
compact?: boolean;
|
||||
layout?: "menu" | "inline";
|
||||
extraMenuItems?: ReactNode;
|
||||
inlinePrefixActions?: ReactNode;
|
||||
showInlineTerminate?: boolean;
|
||||
}
|
||||
|
||||
type SessionCommandResponse = {
|
||||
@@ -52,7 +57,14 @@ const fallbackMessages: Record<Exclude<SessionActionKind, "message">, string> =
|
||||
const unsupportedPlaybackControlCopy =
|
||||
"This session does not support live pause, resume, or messages. Stop and Terminate can still end playback.";
|
||||
|
||||
export function AdminSessionActions({ session, compact = false }: AdminSessionActionsProps) {
|
||||
export function AdminSessionActions({
|
||||
session,
|
||||
compact = false,
|
||||
layout = "menu",
|
||||
extraMenuItems,
|
||||
inlinePrefixActions,
|
||||
showInlineTerminate = false,
|
||||
}: AdminSessionActionsProps) {
|
||||
const [pendingAction, setPendingAction] = useState<SessionActionKind | null>(null);
|
||||
const [messageOpen, setMessageOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -123,58 +135,110 @@ export function AdminSessionActions({ session, compact = false }: AdminSessionAc
|
||||
}
|
||||
}
|
||||
|
||||
const terminateButton = showInlineTerminate ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
compact
|
||||
? "border-destructive/40 text-destructive hover:bg-destructive/10 h-7 gap-1.5 px-2 text-[11px]"
|
||||
: "border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
}
|
||||
onClick={() => void runAction("terminate")}
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
<OctagonAlert className="h-3.5 w-3.5" />
|
||||
Terminate
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const menu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={compact ? "h-7 w-7" : "h-8 w-8"}
|
||||
aria-label="Session actions"
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{layout === "menu" ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
{supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"}
|
||||
</DropdownMenuLabel>
|
||||
{supportsPlaybackControl ? (
|
||||
<DropdownMenuItem onSelect={() => void runAction(primaryAction.action)}>
|
||||
{primaryAction.action === "pause" ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{primaryAction.label}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs leading-5">
|
||||
{unsupportedPlaybackControlCopy}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void runAction("stop")}>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuLabel>Session Actions</DropdownMenuLabel>
|
||||
)}
|
||||
{extraMenuItems ? (
|
||||
<>
|
||||
{layout === "menu" ? <DropdownMenuSeparator /> : null}
|
||||
{extraMenuItems}
|
||||
</>
|
||||
) : null}
|
||||
{supportsPlaybackControl ? (
|
||||
<DropdownMenuItem onSelect={() => setMessageOpen(true)}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Message…
|
||||
</DropdownMenuItem>
|
||||
) : layout === "inline" ? (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs leading-5">
|
||||
{unsupportedPlaybackControlCopy}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{showInlineTerminate ? null : (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void runAction("terminate")}>
|
||||
<OctagonAlert className="h-4 w-4" />
|
||||
Terminate
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={compact ? "h-7 w-7" : "h-8 w-8"}
|
||||
aria-label="Session actions"
|
||||
disabled={pendingAction !== null}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
{supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"}
|
||||
</DropdownMenuLabel>
|
||||
{supportsPlaybackControl ? (
|
||||
<DropdownMenuItem onSelect={() => void runAction(primaryAction.action)}>
|
||||
{primaryAction.action === "pause" ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{primaryAction.label}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs leading-5">
|
||||
{unsupportedPlaybackControlCopy}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void runAction("stop")}>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
{supportsPlaybackControl ? (
|
||||
<DropdownMenuItem onSelect={() => setMessageOpen(true)}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Message…
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void runAction("terminate")}>
|
||||
<OctagonAlert className="h-4 w-4" />
|
||||
Terminate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{layout === "inline" ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{inlinePrefixActions}
|
||||
{terminateButton}
|
||||
{menu}
|
||||
</div>
|
||||
) : (
|
||||
menu
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={supportsPlaybackControl && messageOpen}
|
||||
|
||||
@@ -229,8 +229,15 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
|
||||
return (
|
||||
<aside className="border-sidebar-border/70 bg-sidebar/92 fixed top-0 bottom-0 left-0 z-40 flex w-[240px] flex-col border-r backdrop-blur-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-5 pt-6 pb-1">
|
||||
<SiloBrand className="h-12 w-[112px]" />
|
||||
<div className="flex items-center gap-2.5 px-5 pt-6 pb-4">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={onNavigate}
|
||||
aria-label="Go to app home"
|
||||
className="focus-visible:ring-ring/50 inline-flex rounded-md transition-opacity hover:opacity-85 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<SiloBrand className="h-12 w-[112px]" />
|
||||
</Link>
|
||||
</div>
|
||||
{/* Nav sections */}
|
||||
<nav
|
||||
|
||||
@@ -22,9 +22,26 @@ if (typeof window !== "undefined" && !window.HTMLElement.prototype.hasPointerCap
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const apiClientMocks = vi.hoisted(() => {
|
||||
class ApiClientErrorMock extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ApiClientErrorMock,
|
||||
fetchMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { fetchMock } = apiClientMocks;
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
ApiClientError: apiClientMocks.ApiClientErrorMock,
|
||||
api: (path: string, options?: unknown) => fetchMock(path, options),
|
||||
}));
|
||||
|
||||
@@ -328,7 +345,7 @@ describe("CollectionTemplateGallery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the core defaults bundle and displays failures", async () => {
|
||||
it("queues the core defaults bundle apply job", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderGallery();
|
||||
|
||||
@@ -339,21 +356,11 @@ describe("CollectionTemplateGallery", () => {
|
||||
fetchMock.mockImplementation((path: string) => {
|
||||
if (path === "/admin/collections/templates") return Promise.resolve(catalogResponse);
|
||||
if (path === "/admin/collections/template-bundles") return Promise.resolve(bundlesResponse);
|
||||
if (path === "/admin/collections/template-bundles/core_defaults/apply") {
|
||||
if (path === "/admin/collections/template-bundles/core_defaults/apply-job") {
|
||||
return Promise.resolve({
|
||||
bundle_id: "core_defaults",
|
||||
dry_run: false,
|
||||
created: [],
|
||||
skipped: [],
|
||||
failed: [
|
||||
{
|
||||
template_id: "mdblist_top_horror",
|
||||
template_title: "Top Horror Movies",
|
||||
library_id: 1,
|
||||
library_name: "Movies",
|
||||
reason: "sync failed",
|
||||
},
|
||||
],
|
||||
id: "job-1",
|
||||
job_type: "template_bundle_apply",
|
||||
status: "queued",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected path: ${path}`);
|
||||
@@ -363,8 +370,13 @@ describe("CollectionTemplateGallery", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Apply Defaults/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Created 0; skipped 0; failed 1/i)).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/admin/collections/template-bundles/core_defaults/apply-job",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"library_ids":[1]'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByText(/Movies \/ Top Horror Movies: sync failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+80
-101
@@ -8,8 +8,6 @@ import AppSidebar from "@/components/AppSidebar";
|
||||
import ServerActivity from "@/components/ServerActivity";
|
||||
import { SiloBrand } from "@/components/SiloBrand";
|
||||
import { GlobalSearch } from "@/components/GlobalSearch";
|
||||
import { RealtimeEventsProvider } from "@/components/RealtimeEventsProvider";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalogSearchParams";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -114,110 +112,91 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}, [mobileHeaderHidden]);
|
||||
|
||||
return (
|
||||
<RealtimeEventsProvider>
|
||||
<LayoutEventChannels />
|
||||
{isAdmin && <AdminEventChannels />}
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<GlobalSearch />
|
||||
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar — hidden below lg */}
|
||||
<div className="hidden lg:block">
|
||||
<AppSidebar collapsed={isDetailImmersion} />
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<GlobalSearch />
|
||||
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar — hidden below lg */}
|
||||
<div className="hidden lg:block">
|
||||
<AppSidebar collapsed={isDetailImmersion} />
|
||||
</div>
|
||||
|
||||
{/* Mobile header — visible below lg. Slides up on scroll-down within
|
||||
the Calendar route to free vertical space; pulling up reveals it. */}
|
||||
<div
|
||||
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
|
||||
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
|
||||
<SiloBrand className="h-10 w-[94px]" />
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
|
||||
{/* Mobile header — visible below lg. Slides up on scroll-down within
|
||||
the Calendar route to free vertical space; pulling up reveals it. */}
|
||||
<div
|
||||
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
|
||||
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
|
||||
<SiloBrand className="h-10 w-[94px]" />
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewTransitionLink
|
||||
to={buildQueryCatalogHref()}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</ViewTransitionLink>
|
||||
{isAdmin && <ServerActivity hideWhenEmpty />}
|
||||
<Link
|
||||
to="/settings/playback"
|
||||
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
|
||||
>
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewTransitionLink
|
||||
to={buildQueryCatalogHref()}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</ViewTransitionLink>
|
||||
{isAdmin && <ServerActivity hideWhenEmpty />}
|
||||
<Link
|
||||
to="/settings/playback"
|
||||
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
|
||||
>
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AppSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AppSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
|
||||
{isAdmin && (
|
||||
<div className="fixed top-6 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity hideWhenEmpty />
|
||||
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
|
||||
{isAdmin && (
|
||||
<div className="fixed top-6 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity hideWhenEmpty />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content — offset by sidebar width on desktop */}
|
||||
<main
|
||||
id="main-content"
|
||||
className={`main-transition relative min-h-screen ${
|
||||
isDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
|
||||
} ${isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""}`}
|
||||
style={{ viewTransitionName: "main-content" }}
|
||||
>
|
||||
{needsNoPadding ? (
|
||||
children
|
||||
) : (
|
||||
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content — offset by sidebar width on desktop */}
|
||||
<main
|
||||
id="main-content"
|
||||
className={`main-transition relative min-h-screen ${
|
||||
isDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
|
||||
} ${isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""}`}
|
||||
style={{ viewTransitionName: "main-content" }}
|
||||
>
|
||||
{needsNoPadding ? (
|
||||
children
|
||||
) : (
|
||||
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</RealtimeEventsProvider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutEventChannels() {
|
||||
useEventChannel("catalog");
|
||||
useEventChannel("user_state");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Subscribe to admin WS channels so ServerActivity receives live updates. */
|
||||
function AdminEventChannels() {
|
||||
useEventChannel("sessions");
|
||||
useEventChannel("tasks");
|
||||
useEventChannel("scans");
|
||||
useEventChannel("jobs");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,16 @@ import {
|
||||
type RealtimeEventsContextValue,
|
||||
} from "@/components/realtimeEventsContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { adminKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
|
||||
import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
import { adminKeys, catalogKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
|
||||
import {
|
||||
invalidateMediaSurfaceQueries,
|
||||
updateCatalogItemDetail,
|
||||
} from "@/hooks/queries/mediaSurfaceRefresh";
|
||||
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
interface JobWaiter {
|
||||
timeoutId: number;
|
||||
@@ -35,9 +40,20 @@ interface UserStatePayload {
|
||||
profile_id: string;
|
||||
content_id?: string;
|
||||
series_id?: string;
|
||||
change: "progress" | "favorite" | "watchlist" | "history" | "watched";
|
||||
change: "progress" | "favorite" | "watchlist" | "history" | "watched" | "home_dismissal";
|
||||
played?: boolean;
|
||||
is_favorite?: boolean;
|
||||
in_watchlist?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG_ITEM_CHANGED_EVENTS = new Set(["metadata.updated", "catalog.item.changed"]);
|
||||
const DASHBOARD_QUERY_KEYS = [
|
||||
adminKeys.stats(),
|
||||
adminKeys.sessions(),
|
||||
adminKeys.libraries(),
|
||||
adminKeys.users(),
|
||||
] as const;
|
||||
|
||||
function buildEventsUrl(token: string | null, location: Pick<Location, "protocol" | "host">) {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const search = new URLSearchParams();
|
||||
@@ -64,7 +80,7 @@ function parseEventsMessage(value: unknown): EventsStreamMessage | null {
|
||||
}
|
||||
|
||||
function isTerminalJob(job: AdminJob) {
|
||||
return job.status === "completed" || job.status === "failed";
|
||||
return job.status === "completed" || job.status === "failed" || job.status === "cancelled";
|
||||
}
|
||||
|
||||
async function pollAdminJobUntilTerminal(jobId: string): Promise<AdminJob> {
|
||||
@@ -76,6 +92,9 @@ async function pollAdminJobUntilTerminal(jobId: string): Promise<AdminJob> {
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error_message || job.message || "Job failed");
|
||||
}
|
||||
if (job.status === "cancelled") {
|
||||
throw new Error(job.message || "Job cancelled");
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
@@ -145,31 +164,68 @@ function findCachedAdminJob(queryClient: QueryClient, jobId: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function invalidateCatalogState(queryClient: QueryClient, itemId?: string) {
|
||||
function invalidateDashboardQueries(queryClient: QueryClient, allowRefetch: boolean) {
|
||||
for (const queryKey of DASHBOARD_QUERY_KEYS) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
refetchType: allowRefetch ? "active" : "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateCatalogState(
|
||||
queryClient: QueryClient,
|
||||
options: { itemId?: string; allowDashboardRefetch: boolean },
|
||||
) {
|
||||
const { itemId, allowDashboardRefetch } = options;
|
||||
void invalidateMediaSurfaceQueries(queryClient, itemId ? { itemId } : {}).then(() => {
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraries() });
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
||||
void queryClient.refetchQueries({ queryKey: catalogKeys.all, type: "active" });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.libraries(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.stats(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
}
|
||||
|
||||
function handleJobSideEffects(queryClient: QueryClient, job: AdminJob, eventName: string) {
|
||||
function handleJobSideEffects(
|
||||
queryClient: QueryClient,
|
||||
job: AdminJob,
|
||||
eventName: string,
|
||||
allowDashboardRefetch: boolean,
|
||||
) {
|
||||
if (job.job_type === "delete_library") {
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraries() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.libraries(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
}
|
||||
|
||||
if (eventName === "job.completed" && job.job_type === "catalog_import") {
|
||||
invalidateCatalogState(queryClient);
|
||||
invalidateCatalogState(queryClient, { allowDashboardRefetch });
|
||||
}
|
||||
|
||||
if (eventName === "job.completed" && job.job_type === "delete_library") {
|
||||
invalidateCatalogState(queryClient);
|
||||
invalidateCatalogState(queryClient, { allowDashboardRefetch });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateSessions(queryClient: QueryClient, sessions: AdminSession[]) {
|
||||
function hydrateSessions(
|
||||
queryClient: QueryClient,
|
||||
sessions: AdminSession[],
|
||||
allowDashboardUpdates: boolean,
|
||||
) {
|
||||
if (!allowDashboardUpdates) {
|
||||
invalidateDashboardQueries(queryClient, false);
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(adminKeys.sessions(), sessions);
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
||||
}
|
||||
@@ -182,6 +238,7 @@ function hydrateTasks(queryClient: QueryClient, tasks: TaskInfo[]) {
|
||||
}
|
||||
|
||||
function applyTaskUpdate(queryClient: QueryClient, task: TaskInfo) {
|
||||
const previousTask = queryClient.getQueryData<TaskInfo>(adminKeys.task(task.key));
|
||||
queryClient.setQueryData<TaskInfo[]>(adminKeys.tasks(), (existing) => {
|
||||
const tasks = existing ? [...existing] : [];
|
||||
const index = tasks.findIndex((entry) => entry.key === task.key);
|
||||
@@ -193,6 +250,15 @@ function applyTaskUpdate(queryClient: QueryClient, task: TaskInfo) {
|
||||
return tasks.sort((left, right) => left.key.localeCompare(right.key));
|
||||
});
|
||||
queryClient.setQueryData(adminKeys.task(task.key), task);
|
||||
|
||||
if (
|
||||
task.state === "idle" &&
|
||||
task.last_execution?.completed_at &&
|
||||
previousTask?.last_execution?.completed_at !== task.last_execution.completed_at
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.taskHistory(task.key) });
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.taskMetrics(task.key) });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateScans(queryClient: QueryClient, scans: ScanRun[]) {
|
||||
@@ -220,6 +286,7 @@ function applyScanUpdate(queryClient: QueryClient, scan: ScanRun, eventName: str
|
||||
eventName === "scan.cancelled"
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraries() });
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,19 +316,46 @@ function updateHistoryImportCaches(queryClient: QueryClient, run?: HistoryImport
|
||||
});
|
||||
}
|
||||
|
||||
function handleUserStateEvent(queryClient: QueryClient, payload: UserStatePayload) {
|
||||
function handleUserStateEvent(
|
||||
queryClient: QueryClient,
|
||||
payload: UserStatePayload,
|
||||
activeProfileID: string | null | undefined,
|
||||
allowDashboardRefetch: boolean,
|
||||
) {
|
||||
if (payload.profile_id && activeProfileID && payload.profile_id !== activeProfileID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.content_id) {
|
||||
updateCatalogItemDetail(queryClient, payload.content_id, (detail) => ({
|
||||
...detail,
|
||||
user_state: {
|
||||
played: payload.played ?? detail.user_state?.played ?? false,
|
||||
is_favorite: payload.is_favorite ?? detail.user_state?.is_favorite ?? false,
|
||||
in_watchlist: payload.in_watchlist ?? detail.user_state?.in_watchlist ?? false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
void invalidateMediaSurfaceQueries(
|
||||
queryClient,
|
||||
payload.content_id ? { itemId: payload.content_id } : {},
|
||||
).then(() => {
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: adminKeys.stats(),
|
||||
refetchType: allowDashboardRefetch ? "active" : "none",
|
||||
});
|
||||
}
|
||||
|
||||
export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { user, profile } = useAuth();
|
||||
const pageActivity = usePageActivity();
|
||||
const location = useLocation();
|
||||
const isDashboardRoute = location.pathname === "/admin" || location.pathname === "/admin/";
|
||||
const allowDashboardRealtimeUpdates = !isDashboardRoute || pageActivity.canPollDashboard;
|
||||
const [connectionState, setConnectionState] = useState<RealtimeConnectionState>("connecting");
|
||||
const reconnectTimerRef = useRef<number | undefined>(undefined);
|
||||
const closingRef = useRef(false);
|
||||
@@ -272,6 +366,14 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
const channelHandlersRef = useRef(new Map<EventChannel, Map<number, EventChannelHandlers>>());
|
||||
const nextHandlerIDRef = useRef(1);
|
||||
const waitersRef = useRef(new Map<string, JobWaiter>());
|
||||
const activeProfileIDRef = useRef<string | null | undefined>(profile?.id);
|
||||
const canApplyRealtimeUpdatesRef = useRef(pageActivity.canApplyRealtimeUpdates);
|
||||
const allowDashboardRealtimeUpdatesRef = useRef(allowDashboardRealtimeUpdates);
|
||||
const shouldCatchUpOnFocusRef = useRef(!pageActivity.canApplyRealtimeUpdates);
|
||||
|
||||
activeProfileIDRef.current = profile?.id;
|
||||
canApplyRealtimeUpdatesRef.current = pageActivity.canApplyRealtimeUpdates;
|
||||
allowDashboardRealtimeUpdatesRef.current = allowDashboardRealtimeUpdates;
|
||||
|
||||
const settleWaiterRef = useRef<(job: AdminJob) => void>(() => {});
|
||||
settleWaiterRef.current = (job: AdminJob) => {
|
||||
@@ -285,6 +387,10 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
waiter.resolve(job);
|
||||
return;
|
||||
}
|
||||
if (job.status === "cancelled") {
|
||||
waiter.reject(new Error(job.message || "Job cancelled"));
|
||||
return;
|
||||
}
|
||||
waiter.reject(new Error(job.error_message || job.message || "Job failed"));
|
||||
};
|
||||
|
||||
@@ -338,7 +444,11 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
break;
|
||||
case "sessions":
|
||||
hydrateSessions(queryClient, (message.data as AdminSession[]) ?? []);
|
||||
hydrateSessions(
|
||||
queryClient,
|
||||
(message.data as AdminSession[]) ?? [],
|
||||
allowDashboardRealtimeUpdatesRef.current,
|
||||
);
|
||||
break;
|
||||
case "tasks":
|
||||
hydrateTasks(queryClient, (message.data as TaskInfo[]) ?? []);
|
||||
@@ -358,25 +468,37 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
function handleEvent(message: EventsEventMessage) {
|
||||
switch (message.channel) {
|
||||
case "catalog":
|
||||
if (message.event === "metadata.updated") {
|
||||
invalidateCatalogState(
|
||||
queryClient,
|
||||
typeof message.data === "object" && message.data && "content_id" in message.data
|
||||
? (message.data as { content_id?: string }).content_id
|
||||
: undefined,
|
||||
);
|
||||
if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) {
|
||||
invalidateCatalogState(queryClient, {
|
||||
itemId:
|
||||
typeof message.data === "object" && message.data && "content_id" in message.data
|
||||
? (message.data as { content_id?: string }).content_id
|
||||
: undefined,
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
} else {
|
||||
invalidateCatalogState(queryClient);
|
||||
invalidateCatalogState(queryClient, {
|
||||
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "jobs":
|
||||
applyAdminJobUpdate(queryClient, message.data as AdminJob);
|
||||
handleJobSideEffects(queryClient, message.data as AdminJob, message.event);
|
||||
handleJobSideEffects(
|
||||
queryClient,
|
||||
message.data as AdminJob,
|
||||
message.event,
|
||||
allowDashboardRealtimeUpdatesRef.current,
|
||||
);
|
||||
settleWaiterRef.current(message.data as AdminJob);
|
||||
break;
|
||||
case "sessions":
|
||||
if (message.event === "sessions.replaced") {
|
||||
hydrateSessions(queryClient, (message.data as AdminSession[]) ?? []);
|
||||
hydrateSessions(
|
||||
queryClient,
|
||||
(message.data as AdminSession[]) ?? [],
|
||||
allowDashboardRealtimeUpdatesRef.current,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "tasks":
|
||||
@@ -391,7 +513,12 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
updateHistoryImportCaches(queryClient, message.data as HistoryImportRun);
|
||||
break;
|
||||
case "user_state":
|
||||
handleUserStateEvent(queryClient, message.data as UserStatePayload);
|
||||
handleUserStateEvent(
|
||||
queryClient,
|
||||
message.data as UserStatePayload,
|
||||
activeProfileIDRef.current,
|
||||
allowDashboardRealtimeUpdatesRef.current,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -403,6 +530,26 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (!pageActivity.canApplyRealtimeUpdates) {
|
||||
shouldCatchUpOnFocusRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!shouldCatchUpOnFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldCatchUpOnFocusRef.current = false;
|
||||
void queryClient.refetchQueries({
|
||||
type: "active",
|
||||
predicate: (query) => !isDashboardQueryKey(query.queryKey),
|
||||
});
|
||||
}, [pageActivity.canApplyRealtimeUpdates, queryClient, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !pageActivity.canApplyRealtimeUpdates) {
|
||||
setConnectionState("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
closingRef.current = false;
|
||||
|
||||
@@ -443,6 +590,9 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (!canApplyRealtimeUpdatesRef.current) {
|
||||
return;
|
||||
}
|
||||
const message = parseEventsMessage(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
@@ -498,7 +648,7 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, [queryClient, user]);
|
||||
}, [pageActivity.canApplyRealtimeUpdates, queryClient, user]);
|
||||
|
||||
const value = useMemo<RealtimeEventsContextValue>(
|
||||
() => ({
|
||||
@@ -514,6 +664,9 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
new Error(cachedJob.error_message || cachedJob.message || "Job failed"),
|
||||
);
|
||||
}
|
||||
if (cachedJob.status === "cancelled") {
|
||||
return Promise.reject(new Error(cachedJob.message || "Job cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== "admin" || connectionState !== "live") {
|
||||
@@ -575,3 +728,14 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export { buildEventsUrl };
|
||||
|
||||
function isDashboardQueryKey(queryKey: unknown) {
|
||||
return (
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "admin" &&
|
||||
(queryKey[1] === "stats" ||
|
||||
queryKey[1] === "sessions" ||
|
||||
queryKey[1] === "libraries" ||
|
||||
queryKey[1] === "users")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,11 @@ export default function ServerActivity({ hideWhenEmpty = false }: ServerActivity
|
||||
{totalActive}
|
||||
</span>
|
||||
)}
|
||||
{connectionState !== "live" && totalActive > 0 && (
|
||||
<span className="bg-warning absolute -right-0.5 -bottom-0.5 h-2 w-2 rounded-full" />
|
||||
{connectionState !== "live" && (
|
||||
<span
|
||||
className="absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ExecutionResult } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TaskStatusBadgeProps {
|
||||
result: Pick<ExecutionResult, "status" | "error_message">;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaskStatusBadge({ result, className }: TaskStatusBadgeProps) {
|
||||
const variant =
|
||||
result.status === "failed"
|
||||
? "destructive"
|
||||
: result.status === "cancelled"
|
||||
? "outline"
|
||||
: "secondary";
|
||||
const badge = (
|
||||
<Badge variant={variant} className={cn("shrink-0", className)}>
|
||||
{result.status}
|
||||
</Badge>
|
||||
);
|
||||
const errorMessage = result.error_message?.trim();
|
||||
|
||||
if (result.status !== "failed" || !errorMessage) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help" tabIndex={0}>
|
||||
{badge}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[min(28rem,80vw)] whitespace-pre-wrap">
|
||||
{errorMessage}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -28,5 +28,8 @@ export function formatJobProgress(job: AdminJob) {
|
||||
if (job.status === "completed") {
|
||||
return "Complete";
|
||||
}
|
||||
if (job.status === "cancelled") {
|
||||
return "Cancelled";
|
||||
}
|
||||
return "Starting";
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const baseSession: AdminSession = {
|
||||
file_duration: 3600,
|
||||
started_at: "2026-03-24T12:00:00Z",
|
||||
updated_at: "2026-03-24T12:05:00Z",
|
||||
position_seconds: 300,
|
||||
is_paused: false,
|
||||
has_playback_control: true,
|
||||
audio_track_index: 0,
|
||||
|
||||
@@ -97,6 +97,7 @@ const baseSession: AdminSession = {
|
||||
file_duration: 3600,
|
||||
started_at: "2026-03-24T12:00:00Z",
|
||||
updated_at: "2026-03-24T12:05:00Z",
|
||||
position_seconds: 300,
|
||||
is_paused: false,
|
||||
has_playback_control: true,
|
||||
audio_track_index: 0,
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
CreateLibraryRequest,
|
||||
DeleteLibraryRootOverrideRequest,
|
||||
Library,
|
||||
LibraryMetadataMatchQueueActionResponse,
|
||||
LibraryMetadataMatchQueueDetail,
|
||||
LibraryMetadataMatchQueueStatus,
|
||||
LibraryMountCheckResponse,
|
||||
LibraryRoot,
|
||||
LibraryRootsResponse,
|
||||
@@ -27,6 +30,7 @@ import type {
|
||||
import { adminKeys, libraryKeys } from "../keys";
|
||||
import { toast } from "sonner";
|
||||
import type { LibraryReorderEntry } from "@/pages/adminLibraryOrder";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
|
||||
const ADMIN_STALE_TIME = 30_000;
|
||||
|
||||
@@ -388,6 +392,7 @@ export function useScanAllLibraries() {
|
||||
}
|
||||
|
||||
export function useCancelLibraryScans() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api<{ cancelled: number; library_id: number }>("/scan/cancel", {
|
||||
@@ -396,6 +401,7 @@ export function useCancelLibraryScans() {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Scan cancellation requested");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to cancel scans");
|
||||
@@ -403,6 +409,70 @@ export function useCancelLibraryScans() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useLibraryMetadataMatchQueues() {
|
||||
const pageActivity = usePageActivity();
|
||||
|
||||
return useQuery({
|
||||
queryKey: adminKeys.libraryMatchQueueStatuses(),
|
||||
queryFn: () =>
|
||||
api<LibraryMetadataMatchQueueStatus[]>("/libraries/metadata-match-queue").then(
|
||||
(data) => data ?? [],
|
||||
),
|
||||
staleTime: 0,
|
||||
refetchInterval: pageActivity.canApplyRealtimeUpdates ? 10_000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLibraryMetadataMatchQueueDetail(libraryId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.libraryMatchQueueDetail(libraryId ?? 0),
|
||||
queryFn: () =>
|
||||
api<LibraryMetadataMatchQueueDetail>(
|
||||
`/libraries/${encodeURIComponent(String(libraryId))}/metadata-match-queue?limit=10`,
|
||||
),
|
||||
enabled: libraryId !== null,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetryLibraryMetadataMatchQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api<LibraryMetadataMatchQueueActionResponse>(`/libraries/${id}/metadata-match-queue/retry`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success("Metadata matcher backlog queued");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueDetail(id) });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to rebuild metadata matcher backlog",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelLibraryMetadataMatchQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api<LibraryMetadataMatchQueueActionResponse>(`/libraries/${id}/metadata-match-queue/cancel`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success("Metadata matcher backlog cancelled");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueDetail(id) });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to cancel metadata matcher backlog");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLibraryProviders(libraryId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.libraryProviders(libraryId ?? 0),
|
||||
@@ -508,6 +578,22 @@ export function useRefreshLibraryMetadata() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelAdminJob() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api<AdminJob>(`/admin/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
onSuccess: () => {
|
||||
toast.success("Cancellation requested");
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.jobs("library_refresh") });
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.jobs("__all") });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to cancel job");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const UNMATCHED_PAGE_SIZE = 10;
|
||||
|
||||
export function useUnmatchedLibraryItems(page = 0, search = "") {
|
||||
|
||||
@@ -5,10 +5,14 @@ import { adminKeys } from "../keys";
|
||||
|
||||
const ADMIN_STALE_TIME = 30_000;
|
||||
|
||||
export function fetchAdminStats(options: { refresh?: boolean } = {}) {
|
||||
return api<AdminStats>(`/admin/stats${options.refresh ? "?refresh=1" : ""}`);
|
||||
}
|
||||
|
||||
export function useAdminStats() {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.stats(),
|
||||
queryFn: () => api<AdminStats>("/admin/stats"),
|
||||
queryFn: () => fetchAdminStats(),
|
||||
staleTime: ADMIN_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from "sonner";
|
||||
import { api, ApiClientError } from "@/api/client";
|
||||
import type { ExecutionResult, TaskInfo, TriggerConfig } from "@/api/types";
|
||||
import { adminKeys } from "@/hooks/queries/keys";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
|
||||
export interface MetadataRefreshReasonCount {
|
||||
reason: string;
|
||||
@@ -63,12 +64,14 @@ export function useTaskHistory(key: string) {
|
||||
}
|
||||
|
||||
export function useTaskMetrics(key: string) {
|
||||
const pageActivity = usePageActivity();
|
||||
|
||||
return useQuery({
|
||||
queryKey: adminKeys.taskMetrics(key),
|
||||
queryFn: () => api<MetadataRefreshMetrics>(`/admin/tasks/${encodeURIComponent(key)}/metrics`),
|
||||
enabled: key === "refresh_metadata",
|
||||
staleTime: 0,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: pageActivity.canApplyRealtimeUpdates ? 30_000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
invalidateMediaSurfaceQueries: vi.fn(),
|
||||
removeItemFromHomeSectionCaches: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
@@ -28,6 +29,8 @@ vi.mock("@/api/client", () => ({
|
||||
vi.mock("./mediaSurfaceRefresh", () => ({
|
||||
invalidateMediaSurfaceQueries: (...args: unknown[]) =>
|
||||
mocks.invalidateMediaSurfaceQueries(...args),
|
||||
removeItemFromHomeSectionCaches: (...args: unknown[]) =>
|
||||
mocks.removeItemFromHomeSectionCaches(...args),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -57,6 +60,7 @@ describe("home dismissal query hooks", () => {
|
||||
|
||||
mocks.api.mockReset();
|
||||
mocks.invalidateMediaSurfaceQueries.mockReset();
|
||||
mocks.removeItemFromHomeSectionCaches.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.useMutation.mockReset();
|
||||
@@ -117,6 +121,11 @@ describe("home dismissal query hooks", () => {
|
||||
|
||||
await mutation.onSuccess?.(undefined, variables);
|
||||
|
||||
expect(mocks.removeItemFromHomeSectionCaches).toHaveBeenCalledWith(
|
||||
queryClient,
|
||||
"ep-1",
|
||||
"continue_watching",
|
||||
);
|
||||
expect(mocks.invalidateMediaSurfaceQueries).toHaveBeenCalledWith(queryClient, {
|
||||
itemId: "ep-1",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/api/client";
|
||||
import { invalidateMediaSurfaceQueries } from "./mediaSurfaceRefresh";
|
||||
import {
|
||||
invalidateMediaSurfaceQueries,
|
||||
removeItemFromHomeSectionCaches,
|
||||
} from "./mediaSurfaceRefresh";
|
||||
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
|
||||
|
||||
export type HomeDismissalSurface = "continue_watching" | "next_up";
|
||||
@@ -56,6 +59,7 @@ export function useDismissHomeItem() {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to remove item");
|
||||
},
|
||||
onSuccess: async (_data, variables) => {
|
||||
removeItemFromHomeSectionCaches(queryClient, variables.itemId, variables.surface);
|
||||
await invalidateMediaSurfaceQueries(queryClient, { itemId: variables.itemId });
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
toast.success(dismissalSuccessLabel(variables.surface), {
|
||||
|
||||
@@ -230,9 +230,10 @@ export function useUpdateItemMetadata(contentId: string) {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: (updatedItem) => {
|
||||
queryClient.setQueryData(catalogKeys.itemDetail(contentId), updatedItem);
|
||||
queryClient.invalidateQueries({ queryKey: ["items", "detail", contentId] });
|
||||
onSuccess: () => {
|
||||
void invalidateMediaSurfaceQueries(queryClient, { itemId: contentId }).then(() => {
|
||||
bumpHomeRefreshSignal(queryClient);
|
||||
});
|
||||
toast.success("Metadata saved");
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -324,6 +324,9 @@ export const adminKeys = {
|
||||
libraries: () => ["admin", "libraries"] as const,
|
||||
libraryRoots: (libraryId?: number, state?: string) =>
|
||||
["admin", "libraries", "roots", libraryId ?? "all", state ?? "all"] as const,
|
||||
libraryMatchQueueStatuses: () => ["admin", "libraries", "metadataMatchQueue"] as const,
|
||||
libraryMatchQueueDetail: (libraryId: number) =>
|
||||
["admin", "libraries", "metadataMatchQueue", libraryId] as const,
|
||||
filesystemBrowse: (path: string) => ["admin", "filesystem", "browse", path] as const,
|
||||
librarySkippedRoots: () => ["admin", "libraries", "skippedRoots"] as const,
|
||||
staleMediaIDs: () => ["admin", "libraries", "staleMediaIDs"] as const,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ItemDetail } from "@/api/types";
|
||||
import {
|
||||
catalogKeys,
|
||||
favoriteKeys,
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
sectionKeys,
|
||||
watchlistKeys,
|
||||
} from "./keys";
|
||||
import { invalidateMediaSurfaceQueries } from "./mediaSurfaceRefresh";
|
||||
import {
|
||||
invalidateMediaSurfaceQueries,
|
||||
removeItemFromHomeSectionCaches,
|
||||
setCachedItemDetail,
|
||||
} from "./mediaSurfaceRefresh";
|
||||
|
||||
describe("invalidateMediaSurfaceQueries", () => {
|
||||
it("marks item, section, progress, history, favorites, and watchlist queries stale", async () => {
|
||||
@@ -85,4 +90,59 @@ describe("invalidateMediaSurfaceQueries", () => {
|
||||
|
||||
expect(queryClient.getQueryState(catalogKeys.itemDetail("item-1"))?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("sets all cached detail keys for the mutated item", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(catalogKeys.itemDetail("item-1"), {
|
||||
content_id: "item-1",
|
||||
title: "Old",
|
||||
});
|
||||
queryClient.setQueryData(itemKeys.detail("item-1"), { content_id: "item-1", title: "Old" });
|
||||
|
||||
setCachedItemDetail(queryClient, "item-1", {
|
||||
content_id: "item-1",
|
||||
title: "New",
|
||||
} as ItemDetail);
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<{ title: string }>(catalogKeys.itemDetail("item-1"))?.title,
|
||||
).toBe("New");
|
||||
expect(queryClient.getQueryData<{ title: string }>(itemKeys.detail("item-1"))?.title).toBe(
|
||||
"New",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes dismissed items from cached home section rows", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(sectionKeys.homeItems("continue"), {
|
||||
section: {
|
||||
id: "continue",
|
||||
section_type: "continue_watching",
|
||||
total_count: 2,
|
||||
items: [{ content_id: "item-1" }, { content_id: "item-2" }],
|
||||
},
|
||||
});
|
||||
|
||||
removeItemFromHomeSectionCaches(queryClient, "item-1", "continue_watching");
|
||||
|
||||
type CachedHomeSection = {
|
||||
section: {
|
||||
total_count: number;
|
||||
items: Array<{ content_id: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(
|
||||
queryClient.getQueryData<CachedHomeSection>(sectionKeys.homeItems("continue")),
|
||||
).toEqual({
|
||||
section: {
|
||||
id: "continue",
|
||||
section_type: "continue_watching",
|
||||
total_count: 1,
|
||||
items: [{ content_id: "item-2" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { ItemDetail } from "@/api/types";
|
||||
import type { HomeSectionItemsResponse, ItemDetail } from "@/api/types";
|
||||
import {
|
||||
adminKeys,
|
||||
catalogKeys,
|
||||
@@ -25,17 +25,71 @@ export function updateCatalogItemDetail(
|
||||
) {
|
||||
queryClient.setQueriesData<ItemDetail>(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "catalog" &&
|
||||
query.queryKey[1] === "items" &&
|
||||
query.queryKey[2] === itemId &&
|
||||
query.queryKey[3] === "detail",
|
||||
predicate: (query) => isItemDetailQueryKey(query.queryKey, itemId),
|
||||
},
|
||||
(current) => (current ? updater(current) : current),
|
||||
);
|
||||
}
|
||||
|
||||
export function setCachedItemDetail(queryClient: QueryClient, itemId: string, detail: ItemDetail) {
|
||||
queryClient.setQueriesData<ItemDetail>(
|
||||
{
|
||||
predicate: (query) => isItemDetailQueryKey(query.queryKey, itemId),
|
||||
},
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeItemFromHomeSectionCaches(
|
||||
queryClient: QueryClient,
|
||||
itemId: string,
|
||||
sectionType?: string,
|
||||
) {
|
||||
queryClient.setQueriesData<HomeSectionItemsResponse>(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === sectionKeys.homeItemsRoot()[0] &&
|
||||
query.queryKey[1] === sectionKeys.homeItemsRoot()[1] &&
|
||||
query.queryKey[2] === sectionKeys.homeItemsRoot()[2],
|
||||
},
|
||||
(current) => {
|
||||
if (!current?.section) {
|
||||
return current;
|
||||
}
|
||||
if (sectionType && current.section.section_type !== sectionType) {
|
||||
return current;
|
||||
}
|
||||
const nextItems = current.section.items.filter((item) => item.content_id !== itemId);
|
||||
if (nextItems.length === current.section.items.length) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
section: {
|
||||
...current.section,
|
||||
total_count: Math.max(
|
||||
0,
|
||||
current.section.total_count - (current.section.items.length - nextItems.length),
|
||||
),
|
||||
items: nextItems,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isItemDetailQueryKey(queryKey: unknown, itemId: string) {
|
||||
return (
|
||||
Array.isArray(queryKey) &&
|
||||
((queryKey[0] === "catalog" &&
|
||||
queryKey[1] === "items" &&
|
||||
queryKey[2] === itemId &&
|
||||
queryKey[3] === "detail") ||
|
||||
(queryKey[0] === "items" && queryKey[1] === "detail" && queryKey[2] === itemId))
|
||||
);
|
||||
}
|
||||
|
||||
export async function invalidateMediaSurfaceQueries(
|
||||
queryClient: QueryClient,
|
||||
options: InvalidateMediaSurfaceOptions = {},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PageActivityState {
|
||||
isVisible: boolean;
|
||||
isFocused: boolean;
|
||||
isFrozen: boolean;
|
||||
canPollDashboard: boolean;
|
||||
canApplyRealtimeUpdates: boolean;
|
||||
}
|
||||
|
||||
function readPageActivity(): PageActivityState {
|
||||
const isVisible = typeof document === "undefined" ? true : document.visibilityState === "visible";
|
||||
const isFocused = typeof document === "undefined" ? true : document.hasFocus();
|
||||
const isFrozen = false;
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
isFocused,
|
||||
isFrozen,
|
||||
canPollDashboard: isVisible && isFocused && !isFrozen,
|
||||
canApplyRealtimeUpdates: isVisible && !isFrozen,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePageActivity() {
|
||||
const [state, setState] = useState<PageActivityState>(() => readPageActivity());
|
||||
|
||||
useEffect(() => {
|
||||
let frozen = false;
|
||||
|
||||
const update = () => {
|
||||
const next = readPageActivity();
|
||||
setState({
|
||||
...next,
|
||||
isFrozen: frozen,
|
||||
canPollDashboard: next.isVisible && next.isFocused && !frozen,
|
||||
canApplyRealtimeUpdates: next.isVisible && !frozen,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFreeze = () => {
|
||||
frozen = true;
|
||||
update();
|
||||
};
|
||||
const handleResume = () => {
|
||||
frozen = false;
|
||||
update();
|
||||
};
|
||||
const handlePageShow = () => {
|
||||
frozen = false;
|
||||
update();
|
||||
};
|
||||
const handlePageHide = () => {
|
||||
update();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", update);
|
||||
window.addEventListener("blur", update);
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
document.addEventListener("visibilitychange", update);
|
||||
document.addEventListener("freeze", handleFreeze);
|
||||
document.addEventListener("resume", handleResume);
|
||||
|
||||
update();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", update);
|
||||
window.removeEventListener("blur", update);
|
||||
window.removeEventListener("pageshow", handlePageShow);
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
document.removeEventListener("visibilitychange", update);
|
||||
document.removeEventListener("freeze", handleFreeze);
|
||||
document.removeEventListener("resume", handleResume);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AdminJob } from "@/api/types";
|
||||
|
||||
export function isActiveAdminJob(job: Pick<AdminJob, "status">) {
|
||||
return job.status === "queued" || job.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a library ID from a library refresh admin job payload.
|
||||
*
|
||||
* @param job - Admin job-like object whose `request_payload` may contain an optional `library_id`.
|
||||
* @returns A finite numeric `library_id`, a base-10 `Number.parseInt` result for string
|
||||
* values, or `null` for missing, non-numeric, or non-finite values.
|
||||
*
|
||||
* @example
|
||||
* getLibraryRefreshLibraryID({ request_payload: { library_id: "42" } }); // 42
|
||||
*
|
||||
* @remarks
|
||||
* Finite number inputs, including floats, are returned unchanged rather than coerced.
|
||||
* String inputs use `Number.parseInt(value, 10)`, so parseInt's leading numeric token
|
||||
* rules apply. `NaN`, `Infinity`, and `-Infinity` return `null`.
|
||||
*/
|
||||
export function getLibraryRefreshLibraryID(job: Pick<AdminJob, "request_payload">) {
|
||||
const value = (job.request_payload as { library_id?: unknown }).library_id;
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLibraryRefreshLibraryName(job: Pick<AdminJob, "request_payload">) {
|
||||
const value = (job.request_payload as { library_name?: unknown }).library_name;
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
+653
-218
File diff suppressed because it is too large
Load Diff
@@ -153,8 +153,6 @@ export default function AdminCollections() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<CollectionApplyJobBanner job={latestApplyJob} />
|
||||
|
||||
<div className="page-header gap-5">
|
||||
<div className="space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Collections</h1>
|
||||
@@ -188,6 +186,8 @@ export default function AdminCollections() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollectionApplyJobBanner job={latestApplyJob} />
|
||||
|
||||
<CollectionTemplateGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { AdminSessionActions } from "@/components/AdminSessionActions";
|
||||
import { useAdminStats, useAdminSessions } from "@/hooks/queries/admin/stats";
|
||||
import { fetchAdminStats, useAdminStats, useAdminSessions } from "@/hooks/queries/admin/stats";
|
||||
import { useAdminUsers } from "@/hooks/queries/admin/users";
|
||||
import {
|
||||
useAdminLibraries,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Pause,
|
||||
Library,
|
||||
ScanLine,
|
||||
} from "lucide-react";
|
||||
@@ -38,17 +40,151 @@ import type {
|
||||
AdminUser,
|
||||
WatchProviderActivity,
|
||||
} from "@/api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { adminKeys } from "@/hooks/queries/keys";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
|
||||
const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000;
|
||||
const DASHBOARD_AUTO_REFRESH_MS = 60_000;
|
||||
const RELATIVE_UPDATED_LABEL_TICK_MS = 30_000;
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const statsQuery = useAdminStats();
|
||||
const sessionsQuery = useAdminSessions();
|
||||
const librariesQuery = useAdminLibraries();
|
||||
const usersQuery = useAdminUsers();
|
||||
const scanAll = useScanAllLibraries();
|
||||
const pageActivity = usePageActivity();
|
||||
const manualRefreshStartedAtRef = useRef<number | null>(null);
|
||||
const wasDashboardPollingPausedRef = useRef(!pageActivity.canPollDashboard);
|
||||
const [isManualRefreshPending, setIsManualRefreshPending] = useState(false);
|
||||
const [lastDashboardUpdatedAt, setLastDashboardUpdatedAt] = useState<number | null>(null);
|
||||
const [relativeUpdatedNow, setRelativeUpdatedNow] = useState(() => Date.now());
|
||||
|
||||
const sessions = sessionsQuery.data ?? [];
|
||||
const libraries = librariesQuery.data ?? [];
|
||||
const users = usersQuery.data ?? [];
|
||||
const { refetch: refetchSessions } = sessionsQuery;
|
||||
const { refetch: refetchLibraries } = librariesQuery;
|
||||
const { refetch: refetchUsers } = usersQuery;
|
||||
const hasDashboardData =
|
||||
statsQuery.data !== undefined &&
|
||||
sessionsQuery.data !== undefined &&
|
||||
librariesQuery.data !== undefined &&
|
||||
usersQuery.data !== undefined;
|
||||
const hasStaleDashboardData =
|
||||
statsQuery.isStale || sessionsQuery.isStale || librariesQuery.isStale || usersQuery.isStale;
|
||||
const dashboardDataUpdatedAt = Math.max(
|
||||
statsQuery.dataUpdatedAt,
|
||||
sessionsQuery.dataUpdatedAt,
|
||||
librariesQuery.dataUpdatedAt,
|
||||
usersQuery.dataUpdatedAt,
|
||||
);
|
||||
const lastUpdatedLabel = lastDashboardUpdatedAt
|
||||
? formatRelativeUpdatedLabel(relativeUpdatedNow, lastDashboardUpdatedAt)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDashboardUpdatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setRelativeUpdatedNow(Date.now());
|
||||
}, RELATIVE_UPDATED_LABEL_TICK_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [lastDashboardUpdatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDashboardData && dashboardDataUpdatedAt > 0) {
|
||||
setLastDashboardUpdatedAt(dashboardDataUpdatedAt);
|
||||
}
|
||||
}, [dashboardDataUpdatedAt, hasDashboardData]);
|
||||
|
||||
const refreshDashboard = useCallback(
|
||||
async ({ manual }: { manual: boolean }) => {
|
||||
if (manual) {
|
||||
manualRefreshStartedAtRef.current = Date.now();
|
||||
setIsManualRefreshPending(true);
|
||||
}
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.stats(), refetchType: "none" }),
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.sessions(), refetchType: "none" }),
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.libraries(), refetchType: "none" }),
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.users(), refetchType: "none" }),
|
||||
]);
|
||||
const nextStats = await fetchAdminStats({ refresh: true });
|
||||
queryClient.setQueryData(adminKeys.stats(), nextStats);
|
||||
await Promise.all([refetchSessions(), refetchLibraries(), refetchUsers()]);
|
||||
const refreshedAt = Date.now();
|
||||
setLastDashboardUpdatedAt(refreshedAt);
|
||||
setRelativeUpdatedNow(refreshedAt);
|
||||
} finally {
|
||||
if (manual) {
|
||||
const startedAt = manualRefreshStartedAtRef.current;
|
||||
if (startedAt !== null) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = REFRESH_SPINNER_MIN_VISIBLE_MS - elapsed;
|
||||
if (remaining > 0) {
|
||||
await delay(remaining);
|
||||
}
|
||||
}
|
||||
manualRefreshStartedAtRef.current = null;
|
||||
setIsManualRefreshPending(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryClient, refetchLibraries, refetchSessions, refetchUsers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageActivity.canPollDashboard) {
|
||||
wasDashboardPollingPausedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (isManualRefreshPending) {
|
||||
return;
|
||||
}
|
||||
const resumedPolling = wasDashboardPollingPausedRef.current;
|
||||
wasDashboardPollingPausedRef.current = false;
|
||||
if (
|
||||
resumedPolling &&
|
||||
hasDashboardData &&
|
||||
(hasStaleDashboardData ||
|
||||
!lastDashboardUpdatedAt ||
|
||||
Date.now() - lastDashboardUpdatedAt >= DASHBOARD_AUTO_REFRESH_MS)
|
||||
) {
|
||||
void refreshDashboard({ manual: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastDashboardUpdatedAt &&
|
||||
Date.now() - lastDashboardUpdatedAt >= DASHBOARD_AUTO_REFRESH_MS
|
||||
) {
|
||||
void refreshDashboard({ manual: false });
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshDashboard({ manual: false });
|
||||
}, DASHBOARD_AUTO_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [
|
||||
hasDashboardData,
|
||||
hasStaleDashboardData,
|
||||
isManualRefreshPending,
|
||||
lastDashboardUpdatedAt,
|
||||
pageActivity.canPollDashboard,
|
||||
refreshDashboard,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:space-y-8">
|
||||
@@ -60,18 +196,29 @@ export default function AdminDashboard() {
|
||||
Live sessions, content health, and server activity in one view.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void statsQuery.refetch();
|
||||
void sessionsQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{lastUpdatedLabel && (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
Updated {lastUpdatedLabel}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[8.25rem] justify-center"
|
||||
onClick={() => {
|
||||
void refreshDashboard({ manual: true });
|
||||
}}
|
||||
disabled={isManualRefreshPending}
|
||||
aria-busy={isManualRefreshPending}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${isManualRefreshPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isManualRefreshPending ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -315,18 +462,26 @@ function StreamCard({ session }: { session: AdminSession }) {
|
||||
<div className="surface-panel flex gap-3.5 rounded-2xl border-0 p-3.5 transition-colors duration-150">
|
||||
{/* Poster */}
|
||||
<div
|
||||
className="bg-surface border-border flex w-[70px] flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border"
|
||||
className="bg-surface border-border relative flex w-[70px] flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border"
|
||||
style={{ aspectRatio: "2/3" }}
|
||||
>
|
||||
{session.poster_url ? (
|
||||
<img
|
||||
src={session.poster_url}
|
||||
alt={session.media_title}
|
||||
className="h-full w-full object-cover"
|
||||
className={`h-full w-full object-cover transition-opacity ${session.is_paused ? "opacity-45" : ""}`}
|
||||
/>
|
||||
) : (
|
||||
<Play className="text-primary/40 h-5 w-5" />
|
||||
<Play className={`text-primary/40 h-5 w-5 ${session.is_paused ? "opacity-45" : ""}`} />
|
||||
)}
|
||||
{session.is_paused ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
||||
<div className="border-border/40 bg-background/90 text-foreground inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[10px] font-semibold shadow-sm backdrop-blur">
|
||||
<Pause className="h-3 w-3" />
|
||||
Paused
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
@@ -381,9 +536,7 @@ function StreamCard({ session }: { session: AdminSession }) {
|
||||
</span>
|
||||
)}
|
||||
{(session.profile_name || session.profile_id) && (
|
||||
<span className="border-border bg-surface text-muted-foreground inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold">
|
||||
{session.profile_name || session.profile_id}
|
||||
</span>
|
||||
<SessionProfilePill label={session.profile_name || session.profile_id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -403,6 +556,14 @@ function StreamCard({ session }: { session: AdminSession }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionProfilePill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="border-primary/30 bg-primary/15 text-primary inline-flex max-w-full shrink-0 items-center rounded border px-1.5 py-0.5 align-middle text-[9px] leading-[1.1] font-semibold whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NowPlayingSection({
|
||||
sessions,
|
||||
isLoading,
|
||||
@@ -650,6 +811,7 @@ function ActivityCard({
|
||||
? s.episode_name || `S${s.season_number}E${s.episode_number}`
|
||||
: s.media_title || `File #${s.media_file_id}`;
|
||||
const username = s.username || `User #${s.user_id}`;
|
||||
const profileDisplay = s.profile_name || s.profile_id || "";
|
||||
return (
|
||||
<div
|
||||
key={s.session_id}
|
||||
@@ -661,6 +823,12 @@ function ActivityCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-muted-foreground text-xs leading-relaxed">
|
||||
<span className="text-foreground font-semibold">{username}</span>
|
||||
{profileDisplay ? (
|
||||
<>
|
||||
{" "}
|
||||
<SessionProfilePill label={profileDisplay} />
|
||||
</>
|
||||
) : null}
|
||||
{" started watching "}
|
||||
<Link
|
||||
to={`/admin/history?user_id=${s.user_id}${s.profile_id ? `&profile_id=${encodeURIComponent(s.profile_id)}` : ""}`}
|
||||
@@ -701,6 +869,23 @@ function getTimeAgo(dateStr: string): string {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatRelativeUpdatedLabel(now: number, updatedAt: number) {
|
||||
const elapsedMinutes = Math.floor(Math.max(0, now - updatedAt) / 60_000);
|
||||
if (elapsedMinutes < 1) {
|
||||
return "less than 1 minute ago";
|
||||
}
|
||||
if (elapsedMinutes === 1) {
|
||||
return "1 minute ago";
|
||||
}
|
||||
return `${elapsedMinutes.toLocaleString()} minutes ago`;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function SectionError({ message }: { message: string }) {
|
||||
return <div className="text-destructive py-4 text-center text-sm">{message}</div>;
|
||||
}
|
||||
|
||||
+188
-50
@@ -212,6 +212,7 @@ export default function AdminDevices() {
|
||||
const [recency, setRecency] = useState<Set<Exclude<RecencyBucket, "all">>>(new Set());
|
||||
const [activeView, setActiveView] = useState<SavedView | null>(null);
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>("user");
|
||||
const [overridesOnly, setOverridesOnly] = useState(false);
|
||||
|
||||
// ⌘K focuses the global search input
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -229,51 +230,55 @@ export default function AdminDevices() {
|
||||
|
||||
// anomaly detection
|
||||
const anomalies = useMemo(() => detectAnomalies(devices), [devices]);
|
||||
const scopedDevices = useMemo(
|
||||
() => (overridesOnly ? devices.filter((d) => (d.override_count ?? 0) > 0) : devices),
|
||||
[devices, overridesOnly],
|
||||
);
|
||||
|
||||
// facet counts (computed on the full set, not the filtered set, so the
|
||||
// sidebar always shows accurate "what would I get" numbers)
|
||||
// Facet counts follow the top-level device scope, but not the local facet
|
||||
// filters, so each row shows "what would I get inside this scope".
|
||||
const platformCounts = useMemo(() => {
|
||||
const m = new Map<PlatformKind, number>();
|
||||
devices.forEach((d) => {
|
||||
scopedDevices.forEach((d) => {
|
||||
const k = classifyPlatform(d.device_platform);
|
||||
m.set(k, (m.get(k) ?? 0) + 1);
|
||||
});
|
||||
return m;
|
||||
}, [devices]);
|
||||
}, [scopedDevices]);
|
||||
|
||||
const overrideRangeCounts = useMemo(() => {
|
||||
const m = new Map<Exclude<OverrideRange, "all">, number>();
|
||||
devices.forEach((d) => {
|
||||
scopedDevices.forEach((d) => {
|
||||
const b = bucketOverrideCount(d.override_count ?? 0);
|
||||
m.set(b, (m.get(b) ?? 0) + 1);
|
||||
});
|
||||
return m;
|
||||
}, [devices]);
|
||||
}, [scopedDevices]);
|
||||
|
||||
const recencyCounts = useMemo(() => {
|
||||
const m = new Map<Exclude<RecencyBucket, "all">, number>();
|
||||
devices.forEach((d) => {
|
||||
scopedDevices.forEach((d) => {
|
||||
const b = bucketRecency(d.last_updated);
|
||||
m.set(b, (m.get(b) ?? 0) + 1);
|
||||
});
|
||||
return m;
|
||||
}, [devices]);
|
||||
}, [scopedDevices]);
|
||||
|
||||
const savedViewCounts = useMemo(
|
||||
() => ({
|
||||
anomalies: anomalies.size,
|
||||
recent: devices.filter((d) => (ageInDays(d.last_updated) ?? Infinity) < 7).length,
|
||||
hdr: devices.filter((d) => /tv|appletv|tvos|shield/i.test(deviceKeyHints(d))).length,
|
||||
subtitle: devices.filter((d) => (d.override_count ?? 0) >= 3).length,
|
||||
dormant: devices.filter((d) => (ageInDays(d.last_updated) ?? 0) > 30).length,
|
||||
anomalies: scopedDevices.filter((d) => anomalies.has(d.device_id)).length,
|
||||
recent: scopedDevices.filter((d) => (ageInDays(d.last_updated) ?? Infinity) < 7).length,
|
||||
hdr: scopedDevices.filter((d) => /tv|appletv|tvos|shield/i.test(deviceKeyHints(d))).length,
|
||||
subtitle: scopedDevices.filter((d) => (d.override_count ?? 0) >= 3).length,
|
||||
dormant: scopedDevices.filter((d) => (ageInDays(d.last_updated) ?? 0) > 30).length,
|
||||
}),
|
||||
[anomalies, devices],
|
||||
[anomalies, scopedDevices],
|
||||
);
|
||||
|
||||
// filter pipeline
|
||||
const filteredDevices = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return devices.filter((device) => {
|
||||
return scopedDevices.filter((device) => {
|
||||
// saved view
|
||||
if (activeView === "anomalies" && !anomalies.has(device.device_id)) return false;
|
||||
if (activeView === "recent" && (ageInDays(device.last_updated) ?? Infinity) >= 7)
|
||||
@@ -317,7 +322,7 @@ export default function AdminDevices() {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [devices, anomalies, activeView, platforms, overrideRange, recency, search]);
|
||||
}, [scopedDevices, anomalies, activeView, platforms, overrideRange, recency, search]);
|
||||
|
||||
// group pivot
|
||||
const groups = useMemo(() => buildGroups(filteredDevices, groupBy), [filteredDevices, groupBy]);
|
||||
@@ -332,9 +337,14 @@ export default function AdminDevices() {
|
||||
() => devices.reduce((sum, d) => sum + (d.override_count ?? 0), 0),
|
||||
[devices],
|
||||
);
|
||||
const devicesWithOverrides = useMemo(
|
||||
() => devices.filter((d) => (d.override_count ?? 0) > 0).length,
|
||||
[devices],
|
||||
);
|
||||
|
||||
const hasAnyFilter =
|
||||
activeView !== null ||
|
||||
overridesOnly ||
|
||||
platforms.size > 0 ||
|
||||
overrideRange.size > 0 ||
|
||||
recency.size > 0 ||
|
||||
@@ -345,6 +355,7 @@ export default function AdminDevices() {
|
||||
setPlatforms(new Set());
|
||||
setOverrideRange(new Set());
|
||||
setRecency(new Set());
|
||||
setOverridesOnly(false);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
@@ -369,7 +380,7 @@ export default function AdminDevices() {
|
||||
<Input
|
||||
ref={searchRef}
|
||||
className="bg-background/60 h-9 pr-14 pl-9 text-[13px]"
|
||||
placeholder="Search devices, users, IDs, override keys…"
|
||||
placeholder="Search devices, users, IDs, profiles…"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
@@ -404,6 +415,15 @@ export default function AdminDevices() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start">
|
||||
<DeviceScopeControl
|
||||
overridesOnly={overridesOnly}
|
||||
totalDevices={devices.length}
|
||||
devicesWithOverrides={devicesWithOverrides}
|
||||
onChange={setOverridesOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* main 3-pane */}
|
||||
<div className="grid gap-4 lg:grid-cols-[200px_minmax(300px,360px)_minmax(0,1fr)] xl:grid-cols-[220px_minmax(320px,380px)_minmax(0,1fr)]">
|
||||
{/* filter rail */}
|
||||
@@ -481,6 +501,7 @@ export default function AdminDevices() {
|
||||
deviceId={currentDeviceId}
|
||||
initialProfileId={currentProfileId}
|
||||
anomaly={anomalies.get(currentDeviceId) ?? null}
|
||||
defaultShowAllSettings={!overridesOnly}
|
||||
/>
|
||||
) : (
|
||||
<EmptyDetail />
|
||||
@@ -544,7 +565,7 @@ function FleetPulse({
|
||||
<Histogram counts={sortedCounts} max={maxCount} />
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-muted-foreground/70 text-[10px] tracking-[0.08em] uppercase">
|
||||
group by
|
||||
</span>
|
||||
@@ -562,6 +583,122 @@ function FleetPulse({
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceScopeControl({
|
||||
overridesOnly,
|
||||
totalDevices,
|
||||
devicesWithOverrides,
|
||||
onChange,
|
||||
}: {
|
||||
overridesOnly: boolean;
|
||||
totalDevices: number;
|
||||
devicesWithOverrides: number;
|
||||
onChange: (overridesOnly: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-border/70 bg-background/40 inline-flex overflow-hidden rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(false)}
|
||||
aria-pressed={!overridesOnly}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-3.5 py-2 text-[13px] font-medium transition-colors",
|
||||
!overridesOnly
|
||||
? "bg-surface-hover/70 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>All Devices</span>
|
||||
<span className="font-mono text-[11.5px] tabular-nums opacity-70">{totalDevices}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(true)}
|
||||
aria-pressed={overridesOnly}
|
||||
className={cn(
|
||||
"border-border/60 inline-flex items-center gap-2 border-l px-3.5 py-2 text-[13px] font-medium transition-colors",
|
||||
overridesOnly
|
||||
? "bg-surface-hover/70 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>Devices with Overrides</span>
|
||||
<span className="font-mono text-[11.5px] tabular-nums opacity-70">
|
||||
{devicesWithOverrides}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsScopeControl({
|
||||
showAllSettings,
|
||||
totalSettings,
|
||||
overrideCount,
|
||||
disabled,
|
||||
lockToAllSettings,
|
||||
onChange,
|
||||
}: {
|
||||
showAllSettings: boolean;
|
||||
totalSettings: number;
|
||||
overrideCount: number;
|
||||
disabled: boolean;
|
||||
lockToAllSettings: boolean;
|
||||
onChange: (showAllSettings: boolean) => void;
|
||||
}) {
|
||||
const canShowOverrides = !disabled && !lockToAllSettings;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/70 bg-background/40 inline-flex overflow-hidden rounded-md border",
|
||||
disabled && "opacity-40",
|
||||
)}
|
||||
title={
|
||||
disabled
|
||||
? "This device does not have a registered profile yet"
|
||||
: lockToAllSettings
|
||||
? "This device has no overrides yet, so every device setting is shown"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!disabled) onChange(true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-pressed={showAllSettings}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1 text-[11.5px] transition-colors disabled:cursor-not-allowed",
|
||||
showAllSettings
|
||||
? "bg-surface-hover/70 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>All Settings</span>
|
||||
<span className="font-mono text-[10.5px] tabular-nums opacity-70">{totalSettings}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (canShowOverrides) onChange(false);
|
||||
}}
|
||||
disabled={!canShowOverrides}
|
||||
aria-pressed={!showAllSettings}
|
||||
className={cn(
|
||||
"border-border/60 inline-flex items-center gap-1.5 border-l px-2.5 py-1 text-[11.5px] transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!showAllSettings
|
||||
? "bg-surface-hover/70 text-foreground"
|
||||
: "text-muted-foreground hover:bg-surface-hover/40 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>Overrides</span>
|
||||
<span className="font-mono text-[10.5px] tabular-nums opacity-70">{overrideCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: "destructive" }) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
@@ -1202,12 +1339,12 @@ function EmptyFleet({ hasDevices, onClear }: { hasDevices: boolean; onClear: ()
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-14 text-center">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{hasDevices ? "No devices match your filters" : "No device overrides yet"}
|
||||
{hasDevices ? "No devices match your filters" : "No devices seen yet"}
|
||||
</p>
|
||||
<p className="text-muted-foreground max-w-xs text-[12.5px] leading-relaxed">
|
||||
{hasDevices
|
||||
? "Try a different search term or clear a few filters."
|
||||
: "Overrides appear here as soon as a user tunes a per-device setting."}
|
||||
: "Devices appear here after a client connects with a stable device id."}
|
||||
</p>
|
||||
{hasDevices && (
|
||||
<Button variant="outline" size="sm" onClick={onClear}>
|
||||
@@ -1239,12 +1376,14 @@ function DeviceDetailPanel({
|
||||
deviceId,
|
||||
initialProfileId,
|
||||
anomaly,
|
||||
defaultShowAllSettings,
|
||||
}: {
|
||||
userId: number;
|
||||
deviceId: string;
|
||||
initialProfileId: string | null;
|
||||
/** Reason this device was flagged, or null if no anomaly. */
|
||||
anomaly: DeviceAnomalyReason | null;
|
||||
defaultShowAllSettings: boolean;
|
||||
}) {
|
||||
const isAnomaly = anomaly !== null;
|
||||
const { data, isLoading } = useAdminDeviceDetail(userId, deviceId);
|
||||
@@ -1269,11 +1408,25 @@ function DeviceDetailPanel({
|
||||
// When on, the profile tabs render every device-scoped setting from the
|
||||
// manifest — not just the ones currently overridden — so the admin can
|
||||
// create overrides on settings that haven't been touched yet.
|
||||
const [showAllSettings, setShowAllSettings] = useState(false);
|
||||
const [showAllSettings, setShowAllSettings] = useState(defaultShowAllSettings);
|
||||
const [prevDefaultShowAllSettings, setPrevDefaultShowAllSettings] =
|
||||
useState(defaultShowAllSettings);
|
||||
if (prevDefaultShowAllSettings !== defaultShowAllSettings) {
|
||||
setPrevDefaultShowAllSettings(defaultShowAllSettings);
|
||||
setShowAllSettings(defaultShowAllSettings);
|
||||
}
|
||||
|
||||
const profileTabs = useMemo<DeviceProfileTabEntry[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<string, DeviceProfileTabEntry>();
|
||||
for (const profile of data.profiles ?? []) {
|
||||
const profileId = profile.profile_id || UNKNOWN_PROFILE_ID;
|
||||
grouped.set(profileId, {
|
||||
profileId,
|
||||
profileName: profile.profile_name || profileId,
|
||||
settings: [],
|
||||
});
|
||||
}
|
||||
for (const setting of data.settings) {
|
||||
const profileId = setting.profile_id || UNKNOWN_PROFILE_ID;
|
||||
const existing = grouped.get(profileId);
|
||||
@@ -1312,7 +1465,9 @@ function DeviceDetailPanel({
|
||||
}
|
||||
|
||||
const kind = classifyPlatform(data.device_platform);
|
||||
const totalOverrides = data.settings.length;
|
||||
const totalOverrides = data.override_count ?? data.settings.length;
|
||||
const forceAllSettings = totalOverrides === 0;
|
||||
const effectiveShowAllSettings = forceAllSettings || showAllSettings;
|
||||
const lastUpdate = data.settings
|
||||
.map((s) => s.updated_at)
|
||||
.filter(Boolean)
|
||||
@@ -1483,31 +1638,14 @@ function DeviceDetailPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSettings((v) => !v)}
|
||||
<SettingsScopeControl
|
||||
showAllSettings={effectiveShowAllSettings}
|
||||
totalSettings={ALL_DEVICE_SETTING_KEYS.length}
|
||||
overrideCount={totalOverrides}
|
||||
disabled={profileTabs.length === 0}
|
||||
aria-pressed={showAllSettings}
|
||||
className={cn(
|
||||
"border-border/70 hover:bg-surface-hover/60 inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
showAllSettings
|
||||
? "bg-foreground/10 text-foreground border-foreground/30"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={
|
||||
profileTabs.length === 0
|
||||
? "Add an override on this device first to surface every setting"
|
||||
: showAllSettings
|
||||
? "Collapse to overridden settings only"
|
||||
: `Show every device setting (${ALL_DEVICE_SETTING_KEYS.length})`
|
||||
}
|
||||
>
|
||||
{showAllSettings ? (
|
||||
<>Overrides only</>
|
||||
) : (
|
||||
<>Show all {ALL_DEVICE_SETTING_KEYS.length} settings</>
|
||||
)}
|
||||
</button>
|
||||
lockToAllSettings={forceAllSettings}
|
||||
onChange={setShowAllSettings}
|
||||
/>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/users/${data.user_id}`}>
|
||||
Open user
|
||||
@@ -1564,12 +1702,12 @@ function DeviceDetailPanel({
|
||||
appears next to the rows, not next to the profiles strip.
|
||||
*/}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{totalOverrides === 0 ? (
|
||||
{profileTabs.length === 0 ? (
|
||||
<div className="px-5 py-5 sm:px-6">
|
||||
<div className="border-border/60 rounded-md border border-dashed px-6 py-12 text-center">
|
||||
<p className="text-foreground text-sm font-medium">No overrides on this device</p>
|
||||
<p className="text-foreground text-sm font-medium">No profiles registered</p>
|
||||
<p className="text-muted-foreground mx-auto mt-1 max-w-sm text-xs leading-relaxed">
|
||||
This device has been seen but isn't overriding any per-profile playback settings.
|
||||
The device exists, but it does not have enough profile context to create overrides.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1578,7 +1716,7 @@ function DeviceDetailPanel({
|
||||
key={initialProfileId ?? "default"}
|
||||
profiles={profileTabs}
|
||||
initialProfileId={initialProfileId}
|
||||
showAllSettings={showAllSettings}
|
||||
showAllSettings={effectiveShowAllSettings}
|
||||
device={{
|
||||
userId: data.user_id,
|
||||
deviceId: data.device_id,
|
||||
|
||||
@@ -16,6 +16,10 @@ const mocks = vi.hoisted(() => ({
|
||||
useScanLibrary: vi.fn(),
|
||||
useScanAllLibraries: vi.fn(),
|
||||
useRefreshLibraryMetadata: vi.fn(),
|
||||
useLibraryMetadataMatchQueues: vi.fn(),
|
||||
useLibraryMetadataMatchQueueDetail: vi.fn(),
|
||||
useRetryLibraryMetadataMatchQueue: vi.fn(),
|
||||
useCancelLibraryMetadataMatchQueue: vi.fn(),
|
||||
useConfirmEmptyRootCleanup: vi.fn(),
|
||||
useLibraryProviders: vi.fn(),
|
||||
useSetLibraryProviders: vi.fn(),
|
||||
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
||||
useUnmatchedLibraryItems: vi.fn(),
|
||||
useAdminPlugins: vi.fn(),
|
||||
useCancelLibraryScans: vi.fn(),
|
||||
useCancelAdminJob: vi.fn(),
|
||||
useLibraryRoots: vi.fn(),
|
||||
useUpsertLibraryRootOverride: vi.fn(),
|
||||
useDeleteLibraryRootOverride: vi.fn(),
|
||||
@@ -44,6 +49,14 @@ vi.mock("@/hooks/queries/admin/libraries", () => ({
|
||||
useScanLibrary: (...args: unknown[]) => mocks.useScanLibrary(...args),
|
||||
useScanAllLibraries: (...args: unknown[]) => mocks.useScanAllLibraries(...args),
|
||||
useRefreshLibraryMetadata: (...args: unknown[]) => mocks.useRefreshLibraryMetadata(...args),
|
||||
useLibraryMetadataMatchQueues: (...args: unknown[]) =>
|
||||
mocks.useLibraryMetadataMatchQueues(...args),
|
||||
useLibraryMetadataMatchQueueDetail: (...args: unknown[]) =>
|
||||
mocks.useLibraryMetadataMatchQueueDetail(...args),
|
||||
useRetryLibraryMetadataMatchQueue: (...args: unknown[]) =>
|
||||
mocks.useRetryLibraryMetadataMatchQueue(...args),
|
||||
useCancelLibraryMetadataMatchQueue: (...args: unknown[]) =>
|
||||
mocks.useCancelLibraryMetadataMatchQueue(...args),
|
||||
useConfirmEmptyRootCleanup: (...args: unknown[]) => mocks.useConfirmEmptyRootCleanup(...args),
|
||||
useLibraryProviders: (...args: unknown[]) => mocks.useLibraryProviders(...args),
|
||||
useSetLibraryProviders: (...args: unknown[]) => mocks.useSetLibraryProviders(...args),
|
||||
@@ -52,6 +65,7 @@ vi.mock("@/hooks/queries/admin/libraries", () => ({
|
||||
useDeleteLibraryPoster: (...args: unknown[]) => mocks.useDeleteLibraryPoster(...args),
|
||||
useUnmatchedLibraryItems: (...args: unknown[]) => mocks.useUnmatchedLibraryItems(...args),
|
||||
useCancelLibraryScans: (...args: unknown[]) => mocks.useCancelLibraryScans(...args),
|
||||
useCancelAdminJob: (...args: unknown[]) => mocks.useCancelAdminJob(...args),
|
||||
useLibraryRoots: (...args: unknown[]) => mocks.useLibraryRoots(...args),
|
||||
useUpsertLibraryRootOverride: (...args: unknown[]) => mocks.useUpsertLibraryRootOverride(...args),
|
||||
useDeleteLibraryRootOverride: (...args: unknown[]) => mocks.useDeleteLibraryRootOverride(...args),
|
||||
@@ -128,6 +142,16 @@ describe("AdminLibraries", () => {
|
||||
mocks.useScanLibrary.mockReturnValue(queryState);
|
||||
mocks.useScanAllLibraries.mockReturnValue(queryState);
|
||||
mocks.useRefreshLibraryMetadata.mockReturnValue(queryState);
|
||||
mocks.useLibraryMetadataMatchQueues.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useLibraryMetadataMatchQueueDetail.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useRetryLibraryMetadataMatchQueue.mockReturnValue(queryState);
|
||||
mocks.useCancelLibraryMetadataMatchQueue.mockReturnValue(queryState);
|
||||
mocks.useConfirmEmptyRootCleanup.mockReturnValue(queryState);
|
||||
mocks.useLibraryProviders.mockReturnValue({
|
||||
data: { levels: {} },
|
||||
@@ -148,6 +172,7 @@ describe("AdminLibraries", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCancelLibraryScans.mockReturnValue(queryState);
|
||||
mocks.useCancelAdminJob.mockReturnValue(queryState);
|
||||
mocks.useLibraryRoots.mockReturnValue({ data: [], isLoading: false });
|
||||
mocks.useUpsertLibraryRootOverride.mockReturnValue(queryState);
|
||||
mocks.useDeleteLibraryRootOverride.mockReturnValue(queryState);
|
||||
@@ -162,13 +187,13 @@ describe("AdminLibraries", () => {
|
||||
);
|
||||
expect(markup).toContain('title="Scan Library"');
|
||||
expect(markup).toContain("Scan All");
|
||||
expect(markup).toContain('title="Refresh metadata"');
|
||||
expect(markup).toContain('title="Rescan Metadata"');
|
||||
expect(markup).toContain(
|
||||
"Run another scan after storage returns, or confirm deletion before the next empty-root scan.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the Ambiguous Roots section with a populated row", () => {
|
||||
it("renders the collapsed Ambiguous Roots section with a populated count", () => {
|
||||
mocks.useLibraryRoots.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
@@ -192,21 +217,77 @@ describe("AdminLibraries", () => {
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Ambiguous Roots");
|
||||
expect(markup).toContain("Inception");
|
||||
expect(markup).toContain("/media/movies/Inception (2010)");
|
||||
expect(markup).toContain("Scanner roots that stay visible");
|
||||
});
|
||||
|
||||
it("renders the empty-state inside Ambiguous Roots when no roots exist", () => {
|
||||
it("surfaces pending metadata matcher queue work", () => {
|
||||
mocks.useLibraryMetadataMatchQueues.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
library_id: 1,
|
||||
movie_count: 1,
|
||||
series_count: 2,
|
||||
raw_file_count: 0,
|
||||
total_count: 3,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("3 matching");
|
||||
expect(markup).not.toContain("View backlog");
|
||||
});
|
||||
|
||||
it("shows active scan progress in the library task status row", () => {
|
||||
mocks.useActiveScans.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: "scan-1",
|
||||
library_id: 1,
|
||||
mode: "library",
|
||||
trigger: "manual",
|
||||
status: "running",
|
||||
started_at: "2026-03-23T20:00:00Z",
|
||||
result: {
|
||||
new: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
missing: 0,
|
||||
files_deleted: 0,
|
||||
memberships_removed: 0,
|
||||
items_deleted: 0,
|
||||
matched_files: 0,
|
||||
retried_items: 0,
|
||||
still_unmatched_warnings: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
message: "Processing files",
|
||||
total_files: 20,
|
||||
files_processed: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Processing files · 10 / 20 (50%)");
|
||||
expect(markup).toContain("Full library scan · Entire library");
|
||||
});
|
||||
|
||||
it("renders the collapsed Ambiguous Roots section when no roots exist", () => {
|
||||
// Default useLibraryRoots mock returns { data: [], isLoading: false }. The
|
||||
// section itself still renders (it's gated on libraries.length, not on the
|
||||
// root list), and the table body shows the empty-state copy.
|
||||
// section itself still renders because it is gated on libraries.length.
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Ambiguous Roots");
|
||||
expect(markup).toContain("No ambiguous roots for this library");
|
||||
expect(markup).toContain("Scanner roots that stay visible");
|
||||
});
|
||||
|
||||
it("renders Match instead of Re-match for stale IDs", () => {
|
||||
it("renders Stale External IDs collapsed by default", () => {
|
||||
mocks.useStaleMediaIDs.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
@@ -227,11 +308,11 @@ describe("AdminLibraries", () => {
|
||||
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Match");
|
||||
expect(markup).toContain("Stale External IDs");
|
||||
expect(markup).not.toContain("Re-match");
|
||||
});
|
||||
|
||||
it("renders an unmatched items section when unmatched items exist", () => {
|
||||
it("renders an unmatched items section collapsed by default when unmatched items exist", () => {
|
||||
mocks.useUnmatchedLibraryItems.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
@@ -253,8 +334,7 @@ describe("AdminLibraries", () => {
|
||||
const markup = renderPage();
|
||||
|
||||
expect(markup).toContain("Unmatched Items");
|
||||
expect(markup).toContain("Unknown Film");
|
||||
expect(markup).toContain("unmatched");
|
||||
expect(markup).toContain("Items that could not be matched to any metadata provider.");
|
||||
});
|
||||
|
||||
it("hides unmatched items section when no unmatched items exist", () => {
|
||||
|
||||
+766
-547
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { useAdminUsers } from "@/hooks/queries/admin/users";
|
||||
import { useAdminPlaybackHistory, useAdminUserProfiles } from "@/hooks/queries/admin/history";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { History } from "lucide-react";
|
||||
import { History, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -27,12 +27,15 @@ const ALL_USERS = "all";
|
||||
const ALL_PROFILES = "all";
|
||||
const ALL_COMPLETION = "all";
|
||||
const PAGE_SIZE_OPTIONS = ["25", "50", "100"] as const;
|
||||
const REFRESH_SPINNER_MIN_VISIBLE_MS = 1_000;
|
||||
|
||||
export default function AdminPlaybackHistory() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: users = [] } = useAdminUsers();
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const manualRefreshStartedAtRef = useRef<number | null>(null);
|
||||
const [isManualRefreshPending, setIsManualRefreshPending] = useState(false);
|
||||
|
||||
const selectedUser = searchParams.get("user_id") ?? ALL_USERS;
|
||||
const selectedProfile = searchParams.get("profile_id") ?? ALL_PROFILES;
|
||||
@@ -49,6 +52,26 @@ export default function AdminPlaybackHistory() {
|
||||
limit: 100,
|
||||
});
|
||||
const profiles = useAdminUserProfiles(selectedUserId);
|
||||
const { refetch: refetchHistory } = history;
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
manualRefreshStartedAtRef.current = Date.now();
|
||||
setIsManualRefreshPending(true);
|
||||
try {
|
||||
await refetchHistory();
|
||||
} finally {
|
||||
const startedAt = manualRefreshStartedAtRef.current;
|
||||
if (startedAt !== null) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = REFRESH_SPINNER_MIN_VISIBLE_MS - elapsed;
|
||||
if (remaining > 0) {
|
||||
await delay(remaining);
|
||||
}
|
||||
}
|
||||
manualRefreshStartedAtRef.current = null;
|
||||
setIsManualRefreshPending(false);
|
||||
}
|
||||
}, [refetchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUser === ALL_USERS && selectedProfile !== ALL_PROFILES) {
|
||||
@@ -167,6 +190,19 @@ export default function AdminPlaybackHistory() {
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-[8.25rem] justify-center"
|
||||
onClick={() => {
|
||||
void refreshHistory();
|
||||
}}
|
||||
disabled={isManualRefreshPending}
|
||||
aria-busy={isManualRefreshPending}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${isManualRefreshPending ? "animate-spin" : ""}`} />
|
||||
{isManualRefreshPending ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,3 +442,9 @@ function formatRelative(value: string) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from "react-router";
|
||||
import { Play, Square, Plus, Trash2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TaskStatusBadge } from "@/components/admin/TaskStatusBadge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -54,10 +55,10 @@ function describeTrigger(t: TriggerConfig): string {
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainSec = Math.round(seconds % 60);
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainSec = totalSeconds - minutes * 60;
|
||||
return `${minutes}m ${remainSec}s`;
|
||||
}
|
||||
|
||||
@@ -76,20 +77,26 @@ function RefreshMetricsPanel({ metrics }: { metrics: MetadataRefreshMetrics }) {
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="surface-panel rounded-2xl border-0 p-4">
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">Queue</div>
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">
|
||||
Refresh Backlog
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{metrics.total}</div>
|
||||
</div>
|
||||
<div className="surface-panel rounded-2xl border-0 p-4">
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">Due Now</div>
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">
|
||||
Due for Refresh
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{metrics.due}</div>
|
||||
</div>
|
||||
<div className="surface-panel rounded-2xl border-0 p-4">
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">Leased</div>
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">
|
||||
Processing
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{metrics.leased}</div>
|
||||
</div>
|
||||
<div className="surface-panel rounded-2xl border-0 p-4">
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">
|
||||
Oldest Due
|
||||
Waiting Since
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{formatOptionalDateTime(metrics.oldest_due_at)}
|
||||
@@ -97,7 +104,7 @@ function RefreshMetricsPanel({ metrics }: { metrics: MetadataRefreshMetrics }) {
|
||||
</div>
|
||||
<div className="surface-panel rounded-2xl border-0 p-4">
|
||||
<div className="text-muted-foreground text-xs tracking-[0.16em] uppercase">
|
||||
Oldest Lease
|
||||
Next Claim Timeout
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium">
|
||||
{formatOptionalDateTime(metrics.oldest_lease_expires_at)}
|
||||
@@ -388,17 +395,7 @@ function HistoryRow({
|
||||
</td>
|
||||
<td className="px-4 py-2">{formatDuration(result.duration_ms)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge
|
||||
variant={
|
||||
result.status === "failed"
|
||||
? "destructive"
|
||||
: result.status === "cancelled"
|
||||
? "outline"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{result.status}
|
||||
</Badge>
|
||||
<TaskStatusBadge result={result} />
|
||||
</td>
|
||||
<td className="text-muted-foreground max-w-xs truncate px-4 py-2">
|
||||
{result.error_message || "—"}
|
||||
|
||||
+134
-28
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { AlertTriangle, Play, Square } from "lucide-react";
|
||||
import { AlertTriangle, Loader2, Play, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TaskStatusBadge } from "@/components/admin/TaskStatusBadge";
|
||||
import {
|
||||
useTasks,
|
||||
useRunTask,
|
||||
@@ -10,9 +12,12 @@ import {
|
||||
useTaskMetrics,
|
||||
type MetadataRefreshMetrics,
|
||||
} from "@/hooks/queries/admin/tasks";
|
||||
import { usePageActivity } from "@/hooks/usePageActivity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskCategory, TaskInfo, TriggerConfig } from "@/api/types";
|
||||
|
||||
const CATEGORY_ORDER: TaskCategory[] = ["library", "metadata", "system"];
|
||||
const RUN_BUTTON_MIN_VISIBLE_MS = 1_000;
|
||||
|
||||
const CATEGORY_LABELS: Record<TaskCategory, string> = {
|
||||
library: "Library",
|
||||
@@ -27,8 +32,25 @@ const REFRESH_REASON_LABELS: Record<string, string> = {
|
||||
core_metadata_incomplete: "Core metadata incomplete",
|
||||
};
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
function useTaskClock() {
|
||||
const pageActivity = usePageActivity();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageActivity.canApplyRealtimeUpdates) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [pageActivity.canApplyRealtimeUpdates]);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string, now: number): string {
|
||||
const diff = Math.max(0, now - new Date(dateStr).getTime());
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -39,6 +61,45 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) return "<1ms";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainSec = totalSeconds - minutes * 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainSec}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainMinutes = minutes % 60;
|
||||
return `${hours}h ${remainMinutes}m`;
|
||||
}
|
||||
|
||||
function numberFromResultData(data: Record<string, unknown> | undefined, key: string) {
|
||||
const value = data?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatTaskResultSummary(task: TaskInfo): string | null {
|
||||
const resultData = task.last_execution?.result_data;
|
||||
if (!resultData || task.key !== "refresh_trending_discover") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combos = numberFromResultData(resultData, "combos");
|
||||
const refreshed = numberFromResultData(resultData, "refreshed");
|
||||
const empty = numberFromResultData(resultData, "empty");
|
||||
const failed = numberFromResultData(resultData, "failed");
|
||||
if (combos == null || refreshed == null || empty == null || failed == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (combos === 0) {
|
||||
return "No enabled Trending Discover sections";
|
||||
}
|
||||
|
||||
return `${refreshed} refreshed, ${empty} empty, ${failed} failed`;
|
||||
}
|
||||
|
||||
function describeTrigger(t: TriggerConfig): string {
|
||||
switch (t.type) {
|
||||
case "interval": {
|
||||
@@ -66,12 +127,12 @@ function describeSchedule(triggers: TriggerConfig[]): string | null {
|
||||
return triggers.map(describeTrigger).join(", ");
|
||||
}
|
||||
|
||||
function isOverdue(dateStr: string): boolean {
|
||||
return new Date(dateStr).getTime() < Date.now();
|
||||
function isOverdue(dateStr: string, now: number): boolean {
|
||||
return new Date(dateStr).getTime() < now;
|
||||
}
|
||||
|
||||
function formatNextRun(dateStr: string): string {
|
||||
const diff = new Date(dateStr).getTime() - Date.now();
|
||||
function formatNextRun(dateStr: string, now: number): string {
|
||||
const diff = new Date(dateStr).getTime() - now;
|
||||
if (diff < 0) return "overdue";
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 60) return `in ${minutes}m`;
|
||||
@@ -86,6 +147,10 @@ function formatDateTime(dateStr?: string | null): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }) {
|
||||
const topReasons = metrics.reason_counts.filter((entry) => entry.count > 0);
|
||||
|
||||
@@ -93,23 +158,23 @@ function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }
|
||||
<div className="bg-muted/20 mt-3 rounded-xl p-3">
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-5">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Queue</div>
|
||||
<div className="text-muted-foreground">Refresh Backlog</div>
|
||||
<div className="text-foreground font-medium">{metrics.total}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Due now</div>
|
||||
<div className="text-muted-foreground">Due for Refresh</div>
|
||||
<div className="text-foreground font-medium">{metrics.due}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Leased</div>
|
||||
<div className="text-muted-foreground">Processing</div>
|
||||
<div className="text-foreground font-medium">{metrics.leased}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Oldest due</div>
|
||||
<div className="text-muted-foreground">Waiting Since</div>
|
||||
<div className="text-foreground font-medium">{formatDateTime(metrics.oldest_due_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Oldest lease</div>
|
||||
<div className="text-muted-foreground">Next Claim Timeout</div>
|
||||
<div className="text-foreground font-medium">
|
||||
{formatDateTime(metrics.oldest_lease_expires_at)}
|
||||
</div>
|
||||
@@ -132,14 +197,39 @@ function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }
|
||||
function TaskRow({
|
||||
task,
|
||||
refreshMetrics,
|
||||
now,
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
refreshMetrics?: MetadataRefreshMetrics;
|
||||
now: number;
|
||||
}) {
|
||||
const runTask = useRunTask();
|
||||
const cancelTask = useCancelTask();
|
||||
const runFeedbackStartedAtRef = useRef<number | null>(null);
|
||||
const [isRunFeedbackVisible, setIsRunFeedbackVisible] = useState(false);
|
||||
|
||||
const isRunning = task.state === "running" || task.state === "cancelling";
|
||||
const isShowingRunFeedback = isRunning || isRunFeedbackVisible;
|
||||
const resultSummary = formatTaskResultSummary(task);
|
||||
|
||||
const handleRunTask = async () => {
|
||||
runFeedbackStartedAtRef.current = Date.now();
|
||||
setIsRunFeedbackVisible(true);
|
||||
|
||||
try {
|
||||
await runTask.mutateAsync(task.key);
|
||||
} finally {
|
||||
const startedAt = runFeedbackStartedAtRef.current;
|
||||
if (startedAt !== null) {
|
||||
const remaining = RUN_BUTTON_MIN_VISIBLE_MS - (Date.now() - startedAt);
|
||||
if (remaining > 0) {
|
||||
await delay(remaining);
|
||||
}
|
||||
}
|
||||
runFeedbackStartedAtRef.current = null;
|
||||
setIsRunFeedbackVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-border flex flex-col gap-4 border-b px-4 py-4 last:border-b-0 sm:flex-row sm:items-center">
|
||||
@@ -158,20 +248,24 @@ function TaskRow({
|
||||
{!describeSchedule(task.triggers) && <span>No schedule</span>}
|
||||
{task.last_execution && (
|
||||
<span className="ml-2">
|
||||
· Last run: {formatRelativeTime(task.last_execution.completed_at)}
|
||||
· Last run: {formatRelativeTime(task.last_execution.completed_at, now)}
|
||||
{typeof task.last_execution.duration_ms === "number"
|
||||
? ` · Duration: ${formatDuration(task.last_execution.duration_ms)}`
|
||||
: ""}
|
||||
{resultSummary ? ` · Result: ${resultSummary}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{!task.last_execution && !describeSchedule(task.triggers) && (
|
||||
<span> · Never run</span>
|
||||
)}
|
||||
{task.next_run_at &&
|
||||
(isOverdue(task.next_run_at) ? (
|
||||
(isOverdue(task.next_run_at, now) ? (
|
||||
<span className="text-warning ml-2 inline-flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
Overdue
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2">· Next: {formatNextRun(task.next_run_at)}</span>
|
||||
<span className="ml-2">· Next: {formatNextRun(task.next_run_at, now)}</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -201,31 +295,41 @@ function TaskRow({
|
||||
</div>
|
||||
|
||||
{task.state === "idle" && task.last_execution && (
|
||||
<Badge
|
||||
variant={task.last_execution.status === "failed" ? "destructive" : "secondary"}
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
>
|
||||
{task.last_execution.status}
|
||||
</Badge>
|
||||
<TaskStatusBadge result={task.last_execution} className="self-start sm:self-center" />
|
||||
)}
|
||||
|
||||
{isRunning ? (
|
||||
{isShowingRunFeedback ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
className={cn(
|
||||
"w-full min-w-[8.25rem] justify-center sm:w-auto",
|
||||
isRunning &&
|
||||
task.state !== "cancelling" &&
|
||||
"text-destructive hover:border-destructive/60 hover:bg-destructive/10 hover:text-destructive",
|
||||
)}
|
||||
onClick={() => cancelTask.mutate(task.key)}
|
||||
disabled={cancelTask.isPending || task.state === "cancelling"}
|
||||
disabled={!isRunning || cancelTask.isPending || task.state === "cancelling"}
|
||||
title={isRunning ? "Stop this task run" : undefined}
|
||||
aria-busy={isShowingRunFeedback}
|
||||
>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
{task.state === "cancelling" ? (
|
||||
<Square className="mr-1.5 h-3.5 w-3.5 fill-current" />
|
||||
) : isRunning ? (
|
||||
<Square className="mr-1.5 h-3.5 w-3.5 fill-current" />
|
||||
) : (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{task.state === "cancelling" ? "Stopping..." : isRunning ? "Stop" : "Starting..."}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => runTask.mutate(task.key)}
|
||||
className="w-full min-w-[8.25rem] justify-center sm:w-auto"
|
||||
onClick={() => {
|
||||
void handleRunTask();
|
||||
}}
|
||||
disabled={runTask.isPending}
|
||||
>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
@@ -238,6 +342,7 @@ function TaskRow({
|
||||
|
||||
export default function AdminTasks() {
|
||||
useEventChannel("tasks");
|
||||
const now = useTaskClock();
|
||||
const { data: tasks, isLoading } = useTasks();
|
||||
const { data: refreshMetrics } = useTaskMetrics("refresh_metadata");
|
||||
|
||||
@@ -271,6 +376,7 @@ export default function AdminTasks() {
|
||||
<TaskRow
|
||||
key={task.key}
|
||||
task={task}
|
||||
now={now}
|
||||
refreshMetrics={task.key === "refresh_metadata" ? refreshMetrics : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdminSession } from "@/api/types";
|
||||
import { formatVideoDetail, formatVideoSummary } from "./adminActivityPresentation";
|
||||
import {
|
||||
formatContainerDetail,
|
||||
formatDeliveredAudioSummary,
|
||||
formatDeliveredContainerSummary,
|
||||
formatDeliveredVideoSummary,
|
||||
formatPlaybackDecisionSummary,
|
||||
formatSourceContainerSummary,
|
||||
formatTranscodeModeSummary,
|
||||
formatVideoDetail,
|
||||
formatVideoSummary,
|
||||
normalizeContainerDecision,
|
||||
normalizeStreamDecision,
|
||||
} from "./adminActivityPresentation";
|
||||
|
||||
function makeSession(overrides: Partial<AdminSession> = {}): AdminSession {
|
||||
return {
|
||||
@@ -17,18 +29,24 @@ function makeSession(overrides: Partial<AdminSession> = {}): AdminSession {
|
||||
file_duration: overrides.file_duration ?? 3600,
|
||||
started_at: overrides.started_at ?? new Date().toISOString(),
|
||||
updated_at: overrides.updated_at ?? new Date().toISOString(),
|
||||
position_seconds: overrides.position_seconds ?? 300,
|
||||
is_paused: overrides.is_paused ?? false,
|
||||
audio_track_index: overrides.audio_track_index ?? 0,
|
||||
transcode_audio: overrides.transcode_audio ?? true,
|
||||
stream_bitrate_kbps: overrides.stream_bitrate_kbps ?? 8000,
|
||||
target_bitrate_kbps: overrides.target_bitrate_kbps ?? 8000,
|
||||
transcode_hw_accel: overrides.transcode_hw_accel,
|
||||
source_container: overrides.source_container ?? "mkv",
|
||||
source_bitrate_kbps: overrides.source_bitrate_kbps ?? 9000,
|
||||
source_video_codec: overrides.source_video_codec ?? "h264",
|
||||
source_video_resolution: overrides.source_video_resolution ?? "1080p",
|
||||
video_decision: overrides.video_decision,
|
||||
target_video_codec: overrides.target_video_codec ?? "h264",
|
||||
target_resolution: overrides.target_resolution ?? "1080p",
|
||||
source_audio_codec: overrides.source_audio_codec ?? "aac",
|
||||
source_audio_channels: overrides.source_audio_channels ?? 2,
|
||||
audio_decision: overrides.audio_decision,
|
||||
target_audio_codec: overrides.target_audio_codec,
|
||||
requested_video_codec: overrides.requested_video_codec ?? "hevc",
|
||||
requested_video_resolution: overrides.requested_video_resolution ?? "2160p",
|
||||
};
|
||||
@@ -49,4 +67,92 @@ describe("adminActivityPresentation", () => {
|
||||
),
|
||||
).toBe("Auto-switched from HEVC · 2160p · Output → H.264 · 1080p");
|
||||
});
|
||||
|
||||
it("summarizes the delivered transcode output for compact rows", () => {
|
||||
const session = makeSession({
|
||||
source_video_codec: "hevc",
|
||||
source_video_resolution: "2160p",
|
||||
target_video_codec: "h264",
|
||||
target_resolution: "1080p",
|
||||
source_audio_codec: "eac3",
|
||||
source_audio_channels: 6,
|
||||
target_audio_codec: "aac",
|
||||
});
|
||||
|
||||
expect(formatPlaybackDecisionSummary(session)).toBe("transcode");
|
||||
expect(normalizeContainerDecision(session.play_method)).toBe("hls");
|
||||
expect(normalizeStreamDecision(session.video_decision || session.play_method)).toBe(
|
||||
"transcode",
|
||||
);
|
||||
expect(
|
||||
normalizeStreamDecision(
|
||||
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
||||
),
|
||||
).toBe("transcode");
|
||||
expect(formatSourceContainerSummary(session)).toBe("MKV");
|
||||
expect(formatDeliveredContainerSummary(session)).toBe("HLS");
|
||||
expect(formatContainerDetail(session)).toBe("MKV → HLS");
|
||||
expect(formatDeliveredVideoSummary(session)).toBe("H.264 · 1080p");
|
||||
expect(formatDeliveredAudioSummary(session)).toBe("AAC 5.1");
|
||||
expect(formatTranscodeModeSummary(session)).toBe("HW/SW unknown");
|
||||
});
|
||||
|
||||
it("keeps direct playback summaries on the effective source", () => {
|
||||
const session = makeSession({
|
||||
play_method: "direct",
|
||||
video_decision: "direct",
|
||||
audio_decision: "direct",
|
||||
transcode_audio: false,
|
||||
source_video_codec: "hevc",
|
||||
source_video_resolution: "2160p",
|
||||
target_video_codec: "h264",
|
||||
target_resolution: "1080p",
|
||||
source_audio_codec: "eac3",
|
||||
source_audio_channels: 6,
|
||||
target_audio_codec: "aac",
|
||||
});
|
||||
|
||||
expect(formatPlaybackDecisionSummary(session)).toBe("direct");
|
||||
expect(normalizeContainerDecision(session.play_method)).toBe("direct");
|
||||
expect(normalizeStreamDecision(session.video_decision)).toBe("direct");
|
||||
expect(normalizeStreamDecision(session.audio_decision)).toBe("direct");
|
||||
expect(formatDeliveredContainerSummary(session)).toBe("MKV");
|
||||
expect(formatContainerDetail(session)).toBe("Original container");
|
||||
expect(formatDeliveredVideoSummary(session)).toBe("HEVC · 2160p");
|
||||
expect(formatDeliveredAudioSummary(session)).toBe("EAC3 5.1");
|
||||
expect(formatTranscodeModeSummary(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("labels hardware and software transcode modes", () => {
|
||||
expect(formatTranscodeModeSummary(makeSession({ transcode_hw_accel: "qsv" }))).toBe("HW QSV");
|
||||
expect(formatTranscodeModeSummary(makeSession({ transcode_hw_accel: "none" }))).toBe("SW");
|
||||
expect(
|
||||
formatTranscodeModeSummary(
|
||||
makeSession({
|
||||
play_method: "remux",
|
||||
video_decision: "remux",
|
||||
audio_decision: "transcode",
|
||||
transcode_audio: true,
|
||||
transcode_hw_accel: "qsv",
|
||||
}),
|
||||
),
|
||||
).toBe("Audio SW");
|
||||
});
|
||||
|
||||
it("labels HLS copy-original sessions as container HLS with copied video", () => {
|
||||
const session = makeSession({
|
||||
play_method: "transcode",
|
||||
video_decision: "remux",
|
||||
audio_decision: "transcode",
|
||||
target_video_codec: "copy",
|
||||
target_audio_codec: "aac",
|
||||
transcode_audio: true,
|
||||
});
|
||||
|
||||
expect(normalizeContainerDecision(session.play_method)).toBe("hls");
|
||||
expect(normalizeStreamDecision(session.video_decision)).toBe("copy");
|
||||
expect(normalizeStreamDecision(session.audio_decision)).toBe("transcode");
|
||||
expect(formatDeliveredContainerSummary(session)).toBe("HLS");
|
||||
expect(formatVideoDetail(session)).toBe("Video stream copied");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,12 @@ export function formatDecisionLabel(decision?: string): string {
|
||||
switch (decision) {
|
||||
case "direct":
|
||||
return "Direct";
|
||||
case "copy":
|
||||
return "Copy";
|
||||
case "remux":
|
||||
return "Remux";
|
||||
case "hls":
|
||||
return "HLS";
|
||||
case "transcode":
|
||||
return "Transcode";
|
||||
default:
|
||||
@@ -14,6 +18,82 @@ export function formatDecisionLabel(decision?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeContainerDecision(playMethod?: string): string {
|
||||
switch (playMethod?.trim()) {
|
||||
case "direct":
|
||||
return "direct";
|
||||
case "remux":
|
||||
return "remux";
|
||||
case "transcode":
|
||||
case "hls":
|
||||
return "hls";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStreamDecision(decision?: string): string {
|
||||
switch (decision?.trim()) {
|
||||
case "direct":
|
||||
return "direct";
|
||||
case "copy":
|
||||
case "remux":
|
||||
return "copy";
|
||||
case "transcode":
|
||||
return "transcode";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPlaybackDecisionSummary(session: AdminSession): string {
|
||||
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
|
||||
const audioDecision = normalizeStreamDecision(
|
||||
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
||||
);
|
||||
|
||||
if (videoDecision && videoDecision === audioDecision) {
|
||||
return videoDecision;
|
||||
}
|
||||
if (videoDecision === "transcode" || audioDecision === "transcode") {
|
||||
return "transcode";
|
||||
}
|
||||
if (videoDecision === "copy" || audioDecision === "copy") {
|
||||
return "copy";
|
||||
}
|
||||
return videoDecision || audioDecision || session.play_method || "";
|
||||
}
|
||||
|
||||
export function formatTranscodeModeSummary(session: AdminSession): string | null {
|
||||
const videoDecision = normalizeStreamDecision(session.video_decision || session.play_method);
|
||||
const audioDecision = normalizeStreamDecision(
|
||||
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
||||
);
|
||||
if (videoDecision !== "transcode" && audioDecision !== "transcode") {
|
||||
return null;
|
||||
}
|
||||
if (videoDecision !== "transcode") {
|
||||
return "Audio SW";
|
||||
}
|
||||
|
||||
const hwAccel = session.transcode_hw_accel?.trim().toLowerCase();
|
||||
switch (hwAccel) {
|
||||
case "qsv":
|
||||
return "HW QSV";
|
||||
case "vaapi":
|
||||
return "HW VAAPI";
|
||||
case "none":
|
||||
return "SW";
|
||||
case "auto":
|
||||
return "HW/SW pending";
|
||||
case "":
|
||||
case undefined:
|
||||
return "HW/SW unknown";
|
||||
default:
|
||||
return `HW ${hwAccel.toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSessionBitrate(kbps?: number | null): string | null {
|
||||
if (!kbps || kbps <= 0) {
|
||||
return null;
|
||||
@@ -24,6 +104,37 @@ export function formatSessionBitrate(kbps?: number | null): string | null {
|
||||
return `${Math.round(kbps)} kbps`;
|
||||
}
|
||||
|
||||
export function formatSourceContainerSummary(session: AdminSession): string {
|
||||
return formatContainer(session.source_container) || "Unknown source";
|
||||
}
|
||||
|
||||
export function formatDeliveredContainerSummary(session: AdminSession): string {
|
||||
switch (normalizeContainerDecision(session.play_method)) {
|
||||
case "direct":
|
||||
return formatSourceContainerSummary(session);
|
||||
case "remux":
|
||||
return "Remux";
|
||||
case "hls":
|
||||
return "HLS";
|
||||
default:
|
||||
return formatSourceContainerSummary(session);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContainerDetail(session: AdminSession): string {
|
||||
const source = formatSourceContainerSummary(session);
|
||||
switch (normalizeContainerDecision(session.play_method)) {
|
||||
case "direct":
|
||||
return "Original container";
|
||||
case "remux":
|
||||
return `${source} → Remux`;
|
||||
case "hls":
|
||||
return `${source} → HLS`;
|
||||
default:
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVideoSummary(session: AdminSession): string {
|
||||
return (
|
||||
[formatCodec(session.source_video_codec), session.source_video_resolution?.trim()]
|
||||
@@ -32,8 +143,21 @@ export function formatVideoSummary(session: AdminSession): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function formatVideoDetail(session: AdminSession): string {
|
||||
export function formatDeliveredVideoSummary(session: AdminSession): string {
|
||||
const decision = session.video_decision || session.play_method;
|
||||
if (decision !== "transcode") {
|
||||
return formatVideoSummary(session);
|
||||
}
|
||||
|
||||
return (
|
||||
[formatCodec(session.target_video_codec), session.target_resolution?.trim()]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "Transcoding"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatVideoDetail(session: AdminSession): string {
|
||||
const decision = normalizeStreamDecision(session.video_decision || session.play_method);
|
||||
const requestedSource = formatRequestedVideoSource(session);
|
||||
const target = [formatCodec(session.target_video_codec), session.target_resolution?.trim()]
|
||||
.filter(Boolean)
|
||||
@@ -52,8 +176,8 @@ export function formatVideoDetail(session: AdminSession): string {
|
||||
if (decision === "transcode") {
|
||||
return target ? `Output → ${target}` : "Transcoding";
|
||||
}
|
||||
if (decision === "remux") {
|
||||
return "Container remux";
|
||||
if (decision === "copy") {
|
||||
return "Video stream copied";
|
||||
}
|
||||
if (decision === "direct") {
|
||||
return "No video conversion";
|
||||
@@ -72,9 +196,27 @@ export function formatAudioSummary(session: AdminSession): string {
|
||||
return [lead, format].filter(Boolean).join(" · ") || "Unknown source";
|
||||
}
|
||||
|
||||
export function formatAudioDetail(session: AdminSession): string {
|
||||
export function formatDeliveredAudioSummary(session: AdminSession): string {
|
||||
const decision =
|
||||
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method);
|
||||
if (decision !== "transcode") {
|
||||
return formatAudioSummary(session);
|
||||
}
|
||||
|
||||
return (
|
||||
[
|
||||
formatCodec(session.target_audio_codec || "aac"),
|
||||
formatChannelLayout(session.source_audio_channels),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || "Audio transcode"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAudioDetail(session: AdminSession): string {
|
||||
const decision = normalizeStreamDecision(
|
||||
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method),
|
||||
);
|
||||
if (decision === "transcode") {
|
||||
const target = [
|
||||
formatCodec(session.target_audio_codec || "aac"),
|
||||
@@ -84,8 +226,8 @@ export function formatAudioDetail(session: AdminSession): string {
|
||||
.join(" ");
|
||||
return target ? `→ ${target}` : "Audio transcode";
|
||||
}
|
||||
if (decision === "remux") {
|
||||
return "Container remux";
|
||||
if (decision === "copy") {
|
||||
return "Audio stream copied";
|
||||
}
|
||||
if (decision === "direct") {
|
||||
return "No audio conversion";
|
||||
@@ -113,6 +255,11 @@ function formatCodec(codec?: string): string | null {
|
||||
return trimmed ? formatCodecLabel(trimmed) : null;
|
||||
}
|
||||
|
||||
function formatContainer(container?: string): string | null {
|
||||
const trimmed = container?.trim();
|
||||
return trimmed ? trimmed.toUpperCase() : null;
|
||||
}
|
||||
|
||||
export function getPlaybackSessionTitle(session: AdminSession): string {
|
||||
if (session.series_name && session.season_number != null && session.episode_number != null) {
|
||||
return session.episode_name || `S${session.season_number}E${session.episode_number}`;
|
||||
|
||||
Reference in New Issue
Block a user