From 9f73ac6f1a113be34f5d37ba524dfec5ca895a2a Mon Sep 17 00:00:00 2001 From: zZebrahz Date: Fri, 5 Jun 2026 19:43:20 -0700 Subject: [PATCH] feat(realtime): improve web UI reactivity and admin visibility (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * docs: implementation plan for autoscan arr polling Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): settings and sources schema Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): core types Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): path rewrite helper Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): dedupe imported paths to parent folders Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): arr import-history client Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): settings + sources repository Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): redis scan-suppression seam Co-Authored-By: Claude Sonnet 4.6 * 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) * feat(web): autoscan types and hooks Co-Authored-By: Claude Sonnet 4.6 * 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) * 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) * 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) * docs: design spec for autoscan rewrite-sync from arr root folders Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for autoscan rewrite-sync Co-Authored-By: Claude Opus 4.8 (1M context) * 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 * 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 * 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 * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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 * 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) * feat(plugins): expose scan_source.v1 client resolver Co-Authored-By: Claude Sonnet 4.6 * feat(migrations): autoscan v2 schema (connections + sources, no requests FK) Co-Authored-By: Claude Sonnet 4.6 * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * test(autoscan): fix typo in connectionless-source test name Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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) * 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 * feat(autoscan): accept, normalize, and return source label Co-Authored-By: Claude Opus 4.8 (1M context) * feat(autoscan): add label to source API types * feat(autoscan): shared source-label resolution helper Co-Authored-By: Claude Sonnet 4.6 * refactor(autoscan): polish source-label helper per review Co-Authored-By: Claude Sonnet 4.6 * feat(autoscan): label sources via shared helper + operator label input Co-Authored-By: Claude Sonnet 4.6 * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) 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 Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/silo/main.go | 6 + cmd/silo/main_test.go | 15 + cmd/silo/session_sync.go | 2 + internal/adminjob/cancel_registry.go | 55 + internal/adminjob/item_refresh.go | 6 +- internal/adminjob/library_refresh.go | 16 +- internal/adminjob/repository.go | 79 + internal/adminjob/runner.go | 32 + internal/api/handlers/admin.go | 201 ++- internal/api/handlers/admin_jobs.go | 118 +- internal/api/handlers/admin_jobs_test.go | 201 +++ internal/api/handlers/events_publish.go | 3 +- internal/api/handlers/favorites.go | 18 +- internal/api/handlers/home_dismissals.go | 22 + internal/api/handlers/items.go | 4 +- internal/api/handlers/libraries.go | 393 +++++ internal/api/handlers/libraries_match_test.go | 76 + internal/api/handlers/playback.go | 34 +- internal/api/handlers/playback_sessions.go | 10 +- .../api/handlers/playback_sessions_test.go | 14 + internal/api/handlers/progress.go | 2 +- internal/api/handlers/settings.go | 35 + internal/api/handlers/settings_device_test.go | 75 +- internal/api/handlers/user_state_events.go | 37 +- internal/api/router.go | 27 +- internal/libraryingest/executor.go | 3 +- internal/metadata/movie_match_queue_repo.go | 36 +- .../metadata/series_root_match_queue_repo.go | 17 + internal/metadata/worker.go | 75 + internal/models/admin_job.go | 42 +- internal/models/admin_job_test.go | 54 + internal/models/media_group.go | 1 + internal/notifications/hub.go | 73 +- internal/notifications/hub_test.go | 44 + internal/playback/session.go | 3 + internal/playback/transcode.go | 25 +- internal/playback/transcode_args_test.go | 36 + internal/scanner/file_repo.go | 186 +++ internal/scanqueue/service.go | 3 + internal/taskmanager/manager.go | 3 +- internal/taskmanager/manager_test.go | 78 +- internal/taskmanager/tasks/match_media.go | 2 +- .../taskmanager/tasks/match_media_test.go | 23 + internal/taskmanager/triggers/startup.go | 15 +- internal/taskmanager/triggers/startup_test.go | 48 + internal/transcodenode/server.go | 5 +- internal/userdb/schema.go | 48 +- internal/userdb/settings.go | 58 + internal/userdb/sqlitestore.go | 9 + internal/userstore/pgstore/pgstore.go | 1 + internal/userstore/pgstore/settings.go | 59 + internal/userstore/store.go | 7 + internal/userstore/types.go | 10 + internal/worker/reconciler.go | 22 +- migrations/001_schema.up.sql | 33 +- .../176_playback_session_position.down.sql | 2 + .../176_playback_session_position.up.sql | 2 + ...ayback_session_transcode_hw_accel.down.sql | 2 + ...playback_session_transcode_hw_accel.up.sql | 2 + .../178_admin_job_cancelled_status.down.sql | 23 + .../178_admin_job_cancelled_status.up.sql | 12 + .../179_media_file_match_suppression.down.sql | 4 + .../179_media_file_match_suppression.up.sql | 7 + migrations/180_user_devices.down.sql | 1 + migrations/180_user_devices.up.sql | 62 + web/src/App.tsx | 38 +- web/src/api/types.ts | 67 +- web/src/components/AdminLayout.tsx | 131 +- web/src/components/AdminSessionActions.tsx | 166 ++- web/src/components/AdminSidebar.tsx | 11 +- .../CollectionTemplateGallery.test.tsx | 48 +- web/src/components/Layout.tsx | 181 +-- web/src/components/RealtimeEventsProvider.tsx | 220 ++- web/src/components/ServerActivity.tsx | 7 +- web/src/components/admin/TaskStatusBadge.tsx | 48 + .../adminCatalogMaintenanceFormatters.ts | 3 + .../components/adminSessionActions.test.ts | 1 + .../components/adminSessionActionsUi.test.tsx | 1 + web/src/hooks/queries/admin/libraries.ts | 86 ++ web/src/hooks/queries/admin/stats.ts | 6 +- web/src/hooks/queries/admin/tasks.ts | 5 +- web/src/hooks/queries/homeDismissals.test.ts | 9 + web/src/hooks/queries/homeDismissals.ts | 6 +- web/src/hooks/queries/items.ts | 7 +- web/src/hooks/queries/keys.ts | 3 + .../hooks/queries/mediaSurfaceRefresh.test.ts | 62 +- web/src/hooks/queries/mediaSurfaceRefresh.ts | 68 +- web/src/hooks/usePageActivity.ts | 79 + web/src/lib/adminJobs.ts | 37 + web/src/pages/AdminActivity.tsx | 871 ++++++++--- web/src/pages/AdminCollections.tsx | 4 +- web/src/pages/AdminDashboard.tsx | 223 ++- web/src/pages/AdminDevices.tsx | 238 ++- web/src/pages/AdminLibraries.test.tsx | 106 +- web/src/pages/AdminLibraries.tsx | 1313 ++++++++++------- web/src/pages/AdminPlaybackHistory.tsx | 46 +- web/src/pages/AdminTaskDetail.tsx | 37 +- web/src/pages/AdminTasks.tsx | 162 +- .../pages/adminActivityPresentation.test.ts | 108 +- web/src/pages/adminActivityPresentation.ts | 159 +- 100 files changed, 5829 insertions(+), 1345 deletions(-) create mode 100644 internal/adminjob/cancel_registry.go create mode 100644 internal/api/handlers/playback_sessions_test.go create mode 100644 internal/models/admin_job_test.go create mode 100644 internal/notifications/hub_test.go create mode 100644 internal/taskmanager/tasks/match_media_test.go create mode 100644 internal/taskmanager/triggers/startup_test.go create mode 100644 migrations/176_playback_session_position.down.sql create mode 100644 migrations/176_playback_session_position.up.sql create mode 100644 migrations/177_playback_session_transcode_hw_accel.down.sql create mode 100644 migrations/177_playback_session_transcode_hw_accel.up.sql create mode 100644 migrations/178_admin_job_cancelled_status.down.sql create mode 100644 migrations/178_admin_job_cancelled_status.up.sql create mode 100644 migrations/179_media_file_match_suppression.down.sql create mode 100644 migrations/179_media_file_match_suppression.up.sql create mode 100644 migrations/180_user_devices.down.sql create mode 100644 migrations/180_user_devices.up.sql create mode 100644 web/src/components/admin/TaskStatusBadge.tsx create mode 100644 web/src/hooks/usePageActivity.ts create mode 100644 web/src/lib/adminJobs.ts diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 9ef8fe2f..3bed1f72 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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() diff --git a/cmd/silo/main_test.go b/cmd/silo/main_test.go index d5419357..b5a9cf04 100644 --- a/cmd/silo/main_test.go +++ b/cmd/silo/main_test.go @@ -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) + } }) } } diff --git a/cmd/silo/session_sync.go b/cmd/silo/session_sync.go index 08d699d8..267ba0bb 100644 --- a/cmd/silo/session_sync.go +++ b/cmd/silo/session_sync.go @@ -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, } diff --git a/internal/adminjob/cancel_registry.go b/internal/adminjob/cancel_registry.go new file mode 100644 index 00000000..f34d639c --- /dev/null +++ b/internal/adminjob/cancel_registry.go @@ -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 +} diff --git a/internal/adminjob/item_refresh.go b/internal/adminjob/item_refresh.go index d27321ea..c38d18f8 100644 --- a/internal/adminjob/item_refresh.go +++ b/internal/adminjob/item_refresh.go @@ -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", }) } diff --git a/internal/adminjob/library_refresh.go b/internal/adminjob/library_refresh.go index c504bc11..44a8e691 100644 --- a/internal/adminjob/library_refresh.go +++ b/internal/adminjob/library_refresh.go @@ -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 { diff --git a/internal/adminjob/repository.go b/internal/adminjob/repository.go index a91f3ce9..de2e3406 100644 --- a/internal/adminjob/repository.go +++ b/internal/adminjob/repository.go @@ -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 diff --git a/internal/adminjob/runner.go b/internal/adminjob/runner.go index 197b06c2..31fdc6d5 100644 --- a/internal/adminjob/runner.go +++ b/internal/adminjob/runner.go @@ -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) + } + } +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4c84e8e1..295f5e74 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -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 - } } } diff --git a/internal/api/handlers/admin_jobs.go b/internal/api/handlers/admin_jobs.go index 20ae2ea6..44e64bdc 100644 --- a/internal/api/handlers/admin_jobs.go +++ b/internal/api/handlers/admin_jobs.go @@ -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, diff --git a/internal/api/handlers/admin_jobs_test.go b/internal/api/handlers/admin_jobs_test.go index d7dd5ec1..80b9b6c1 100644 --- a/internal/api/handlers/admin_jobs_test.go +++ b/internal/api/handlers/admin_jobs_test.go @@ -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 +} diff --git a/internal/api/handlers/events_publish.go b/internal/api/handlers/events_publish.go index fcc52f5e..0e4f9df7 100644 --- a/internal/api/handlers/events_publish.go +++ b/internal/api/handlers/events_publish.go @@ -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) } diff --git a/internal/api/handlers/favorites.go b/internal/api/handlers/favorites.go index 4d7abd54..ef562ee2 100644 --- a/internal/api/handlers/favorites.go +++ b/internal/api/handlers/favorites.go @@ -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) diff --git a/internal/api/handlers/home_dismissals.go b/internal/api/handlers/home_dismissals.go index d090b943..b00e2b9c 100644 --- a/internal/api/handlers/home_dismissals.go +++ b/internal/api/handlers/home_dismissals.go @@ -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) } diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index afc5f927..d4dbd372 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -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, diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index 8fe0df76..a8f26282 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -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. diff --git a/internal/api/handlers/libraries_match_test.go b/internal/api/handlers/libraries_match_test.go index fd8017e6..6d36a0a9 100644 --- a/internal/api/handlers/libraries_match_test.go +++ b/internal/api/handlers/libraries_match_test.go @@ -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 { diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 11f1dd57..a4a2bbfd 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -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)) } diff --git a/internal/api/handlers/playback_sessions.go b/internal/api/handlers/playback_sessions.go index 78569003..a101efc6 100644 --- a/internal/api/handlers/playback_sessions.go +++ b/internal/api/handlers/playback_sessions.go @@ -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: diff --git a/internal/api/handlers/playback_sessions_test.go b/internal/api/handlers/playback_sessions_test.go new file mode 100644 index 00000000..e28e795c --- /dev/null +++ b/internal/api/handlers/playback_sessions_test.go @@ -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) + } +} diff --git a/internal/api/handlers/progress.go b/internal/api/handlers/progress.go index 8125985b..1abbb887 100644 --- a/internal/api/handlers/progress.go +++ b/internal/api/handlers/progress.go @@ -258,7 +258,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ item.MediaItemID, "", "progress", - "progress.updated", + userStateEventState{}, ) } } diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 59737dc9..be2d1883 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -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) diff --git a/internal/api/handlers/settings_device_test.go b/internal/api/handlers/settings_device_test.go index 139fecc6..48beb120 100644 --- a/internal/api/handlers/settings_device_test.go +++ b/internal/api/handlers/settings_device_test.go @@ -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 { diff --git a/internal/api/handlers/user_state_events.go b/internal/api/handlers/user_state_events.go index 24c17b5e..03797e61 100644 --- a/internal/api/handlers/user_state_events.go +++ b/internal/api/handlers/user_state_events.go @@ -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, diff --git a/internal/api/router.go b/internal/api/router.go index 1a93ccf0..a8234b11 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) }) } diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index 688e326c..bc53e73f 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -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 }), diff --git a/internal/metadata/movie_match_queue_repo.go b/internal/metadata/movie_match_queue_repo.go index ba7dd186..36240e86 100644 --- a/internal/metadata/movie_match_queue_repo.go +++ b/internal/metadata/movie_match_queue_repo.go @@ -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, diff --git a/internal/metadata/series_root_match_queue_repo.go b/internal/metadata/series_root_match_queue_repo.go index 3995e878..e832d405 100644 --- a/internal/metadata/series_root_match_queue_repo.go +++ b/internal/metadata/series_root_match_queue_repo.go @@ -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 diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index bec12554..1e5efec8 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -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 +} diff --git a/internal/models/admin_job.go b/internal/models/admin_job.go index 28ecf8fc..ec24dc97 100644 --- a/internal/models/admin_job.go +++ b/internal/models/admin_job.go @@ -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"` } diff --git a/internal/models/admin_job_test.go b/internal/models/admin_job_test.go new file mode 100644 index 00000000..a2783d51 --- /dev/null +++ b/internal/models/admin_job_test.go @@ -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) + } + } +} diff --git a/internal/models/media_group.go b/internal/models/media_group.go index 2bae58fe..4aee3335 100644 --- a/internal/models/media_group.go +++ b/internal/models/media_group.go @@ -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 diff --git a/internal/notifications/hub.go b/internal/notifications/hub.go index e317999c..5d8292c8 100644 --- a/internal/notifications/hub.go +++ b/internal/notifications/hub.go @@ -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 diff --git a/internal/notifications/hub_test.go b/internal/notifications/hub_test.go new file mode 100644 index 00000000..4f81da27 --- /dev/null +++ b/internal/notifications/hub_test.go @@ -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) + } + } +} diff --git a/internal/playback/session.go b/internal/playback/session.go index c8a3ad2b..23d0e0ed 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -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 } diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index fe8a3dd2..d83a64d4 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -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") diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index 5a2ec062..f96c536f 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -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", diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index 85711901..959084c5 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -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') diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index 38faa0e5..629ae714 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -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 diff --git a/internal/taskmanager/manager.go b/internal/taskmanager/manager.go index a615077a..bbc8bad0 100644 --- a/internal/taskmanager/manager.go +++ b/internal/taskmanager/manager.go @@ -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. diff --git a/internal/taskmanager/manager_test.go b/internal/taskmanager/manager_test.go index c1993be7..9f7ff7e9 100644 --- a/internal/taskmanager/manager_test.go +++ b/internal/taskmanager/manager_test.go @@ -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)) + } +} diff --git a/internal/taskmanager/tasks/match_media.go b/internal/taskmanager/tasks/match_media.go index 974dff35..69ad779a 100644 --- a/internal/taskmanager/tasks/match_media.go +++ b/internal/taskmanager/tasks/match_media.go @@ -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{ diff --git a/internal/taskmanager/tasks/match_media_test.go b/internal/taskmanager/tasks/match_media_test.go new file mode 100644 index 00000000..f2473df1 --- /dev/null +++ b/internal/taskmanager/tasks/match_media_test.go @@ -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") + } +} diff --git a/internal/taskmanager/triggers/startup.go b/internal/taskmanager/triggers/startup.go index 21ecd9c8..ee2ad3f6 100644 --- a/internal/taskmanager/triggers/startup.go +++ b/internal/taskmanager/triggers/startup.go @@ -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 { diff --git a/internal/taskmanager/triggers/startup_test.go b/internal/taskmanager/triggers/startup_test.go new file mode 100644 index 00000000..2560d1e5 --- /dev/null +++ b/internal/taskmanager/triggers/startup_test.go @@ -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)) + } +} diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 48ed2c27..f4019ded 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -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, }) } diff --git a/internal/userdb/schema.go b/internal/userdb/schema.go index 0c54eaf5..b3cf379d 100644 --- a/internal/userdb/schema.go +++ b/internal/userdb/schema.go @@ -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( diff --git a/internal/userdb/settings.go b/internal/userdb/settings.go index b928bae8..83a16625 100644 --- a/internal/userdb/settings.go +++ b/internal/userdb/settings.go @@ -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 diff --git a/internal/userdb/sqlitestore.go b/internal/userdb/sqlitestore.go index 203a3a99..20009d67 100644 --- a/internal/userdb/sqlitestore.go +++ b/internal/userdb/sqlitestore.go @@ -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) } diff --git a/internal/userstore/pgstore/pgstore.go b/internal/userstore/pgstore/pgstore.go index 9a2cac28..46e79c57 100644 --- a/internal/userstore/pgstore/pgstore.go +++ b/internal/userstore/pgstore/pgstore.go @@ -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 { diff --git a/internal/userstore/pgstore/settings.go b/internal/userstore/pgstore/settings.go index 80799325..55a35e86 100644 --- a/internal/userstore/pgstore/settings.go +++ b/internal/userstore/pgstore/settings.go @@ -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) diff --git a/internal/userstore/store.go b/internal/userstore/store.go index ac492d71..28da107d 100644 --- a/internal/userstore/store.go +++ b/internal/userstore/store.go @@ -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) +} diff --git a/internal/userstore/types.go b/internal/userstore/types.go index 1f6603c5..bc06098e 100644 --- a/internal/userstore/types.go +++ b/internal/userstore/types.go @@ -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 diff --git a/internal/worker/reconciler.go b/internal/worker/reconciler.go index 048e01f1..eda8cd5a 100644 --- a/internal/worker/reconciler.go +++ b/internal/worker/reconciler.go @@ -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 diff --git a/migrations/001_schema.up.sql b/migrations/001_schema.up.sql index 2bdf889e..9a5336d7 100644 --- a/migrations/001_schema.up.sql +++ b/migrations/001_schema.up.sql @@ -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: - diff --git a/migrations/176_playback_session_position.down.sql b/migrations/176_playback_session_position.down.sql new file mode 100644 index 00000000..d1dec280 --- /dev/null +++ b/migrations/176_playback_session_position.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + DROP COLUMN IF EXISTS position_seconds; diff --git a/migrations/176_playback_session_position.up.sql b/migrations/176_playback_session_position.up.sql new file mode 100644 index 00000000..5bb3cf35 --- /dev/null +++ b/migrations/176_playback_session_position.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + ADD COLUMN IF NOT EXISTS position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/migrations/177_playback_session_transcode_hw_accel.down.sql b/migrations/177_playback_session_transcode_hw_accel.down.sql new file mode 100644 index 00000000..53738e05 --- /dev/null +++ b/migrations/177_playback_session_transcode_hw_accel.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + DROP COLUMN IF EXISTS transcode_hw_accel; diff --git a/migrations/177_playback_session_transcode_hw_accel.up.sql b/migrations/177_playback_session_transcode_hw_accel.up.sql new file mode 100644 index 00000000..4c2e2d5f --- /dev/null +++ b/migrations/177_playback_session_transcode_hw_accel.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE playback_sessions_sync + ADD COLUMN IF NOT EXISTS transcode_hw_accel text; diff --git a/migrations/178_admin_job_cancelled_status.down.sql b/migrations/178_admin_job_cancelled_status.down.sql new file mode 100644 index 00000000..a63b7b89 --- /dev/null +++ b/migrations/178_admin_job_cancelled_status.down.sql @@ -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 + ]))); diff --git a/migrations/178_admin_job_cancelled_status.up.sql b/migrations/178_admin_job_cancelled_status.up.sql new file mode 100644 index 00000000..ae0fcfa2 --- /dev/null +++ b/migrations/178_admin_job_cancelled_status.up.sql @@ -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 + ]))); diff --git a/migrations/179_media_file_match_suppression.down.sql b/migrations/179_media_file_match_suppression.down.sql new file mode 100644 index 00000000..2a123119 --- /dev/null +++ b/migrations/179_media_file_match_suppression.down.sql @@ -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; diff --git a/migrations/179_media_file_match_suppression.up.sql b/migrations/179_media_file_match_suppression.up.sql new file mode 100644 index 00000000..e4798c2c --- /dev/null +++ b/migrations/179_media_file_match_suppression.up.sql @@ -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; diff --git a/migrations/180_user_devices.down.sql b/migrations/180_user_devices.down.sql new file mode 100644 index 00000000..2cd42e37 --- /dev/null +++ b/migrations/180_user_devices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.user_devices; diff --git a/migrations/180_user_devices.up.sql b/migrations/180_user_devices.up.sql new file mode 100644 index 00000000..dc696ee3 --- /dev/null +++ b/migrations/180_user_devices.up.sql @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index 050a6c0d..ed501a89 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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" ? : null; +} + +function AdminRealtimeEventChannels() { + useEventChannel("jobs"); + useEventChannel("sessions"); + useEventChannel("tasks"); + useEventChannel("scans"); + return null; +} + export default function App() { return ( @@ -546,14 +565,17 @@ export default function App() { - - - - - - - - + + + + + + + + + + + diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7179d259..7d5ea05a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index fd3daf63..5b8186ea 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -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 ( - - -
- - Skip to content - -
- {/* Desktop sidebar */} -
- -
- - {/* Mobile header */} -
-
- -
-
- ▶ -
- Admin -
-
- -
- - {/* Desktop activity indicator */} -
- -
- - {/* Mobile sidebar drawer */} - - - - Admin Navigation - - setMobileOpen(false)} /> - - - -
-
- -
-
+
+ + Skip to content + +
+ {/* Desktop sidebar */} +
+
- + + {/* Mobile header */} +
+
+ +
+
+ ▶ +
+ Admin +
+
+ +
+ + {/* Desktop activity indicator */} +
+ +
+ + {/* Mobile sidebar drawer */} + + + + Admin Navigation + + setMobileOpen(false)} /> + + + +
+
+ +
+
+
); } - -function AdminLayoutEventChannels() { - useEventChannel("catalog"); - useEventChannel("user_state"); - useEventChannel("jobs"); - useEventChannel("sessions"); - useEventChannel("tasks"); - useEventChannel("scans"); - return null; -} diff --git a/web/src/components/AdminSessionActions.tsx b/web/src/components/AdminSessionActions.tsx index 325be454..74f6ee34 100644 --- a/web/src/components/AdminSessionActions.tsx +++ b/web/src/components/AdminSessionActions.tsx @@ -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, 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(null); const [messageOpen, setMessageOpen] = useState(false); const [message, setMessage] = useState(""); @@ -123,58 +135,110 @@ export function AdminSessionActions({ session, compact = false }: AdminSessionAc } } + const terminateButton = showInlineTerminate ? ( + + ) : null; + + const menu = ( + + + + + + {layout === "menu" ? ( + <> + + {supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"} + + {supportsPlaybackControl ? ( + void runAction(primaryAction.action)}> + {primaryAction.action === "pause" ? ( + + ) : ( + + )} + {primaryAction.label} + + ) : ( + <> +
+ {unsupportedPlaybackControlCopy} +
+ + + )} + void runAction("stop")}> + + Stop + + + ) : ( + Session Actions + )} + {extraMenuItems ? ( + <> + {layout === "menu" ? : null} + {extraMenuItems} + + ) : null} + {supportsPlaybackControl ? ( + setMessageOpen(true)}> + + Message… + + ) : layout === "inline" ? ( + <> +
+ {unsupportedPlaybackControlCopy} +
+ + + ) : null} + {showInlineTerminate ? null : ( + <> + + void runAction("terminate")}> + + Terminate + + + )} +
+
+ ); + return ( <> - - - - - - - {supportsPlaybackControl ? "Playback Actions" : "Limited Playback Actions"} - - {supportsPlaybackControl ? ( - void runAction(primaryAction.action)}> - {primaryAction.action === "pause" ? ( - - ) : ( - - )} - {primaryAction.label} - - ) : ( - <> -
- {unsupportedPlaybackControlCopy} -
- - - )} - void runAction("stop")}> - - Stop - - {supportsPlaybackControl ? ( - setMessageOpen(true)}> - - Message… - - ) : null} - - void runAction("terminate")}> - - Terminate - -
-
+ {layout === "inline" ? ( +
+ {inlinePrefixActions} + {terminateButton} + {menu} +
+ ) : ( + menu + )} {/* Logo */} -
- +
+ + +
{/* Nav sections */}