Compare commits

..
Author SHA1 Message Date
Quick 084a77c34f fix(playback): probe nvenc before auto-selecting 2026-06-07 20:10:40 -04:00
thezak48 b84e2cde1b feat(playback): NVENC support for transcoding 2026-06-08 00:42:41 +01:00
6b1554a86c Fix scoped catalog refresh invalidation (#51)
* Fix scoped catalog refresh invalidation

* Remove duplicate catalog refetch

* fix(web): keep library lists fresh on scoped catalog events

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 18:15:34 -04:00
Quick 480ca44306 fix(recommendations): improve taste seed cold start ranking
Closes #66
2026-06-07 17:42:52 -04:00
d3v1l1989andGitHub ecd85f9ca1 fix(jellycompat): enforce account-level library restrictions (#72)
* fix(jellycompat): enforce account-level library restrictions

The compat AccessFilterFn re-implemented a subset of the access policy
and never loaded users.library_ids, so admin-assigned account library
restrictions did not apply to the Jellyfin-compat API: restricted users
could list, browse, search, and play items from every enabled library.

Replace the hand-rolled closure with the shared access.Resolver via a
new jellycompat.NewScopeAccessFilter adapter so account restrictions,
profile restrictions, user-disabled libraries, and rating/quality
ceilings apply exactly as on the native API. PIN verification is
skipped because compat login already verifies profile PINs.

Also: fail closed (empty allowlist) when scope resolution errors
instead of returning an unrestricted filter; treat an empty allowlist
as restricted-to-nothing in ListUserLibraries instead of unrestricted;
and hide user-disabled libraries from the compat views list.

* test(jellycompat): cover library list filtering via folder source stub

Extract a folderListSource interface for the two FolderRepository
methods directContentService uses (matching the existing browseSource/
itemAccessSource pattern) so ListUserLibraries is unit-testable, and
add coverage for the restricted-allowlist path and the user-disabled
library filtering in the views list.
2026-06-07 17:19:40 -04:00
QuickandGitHub f9bb94a299 [codex] Fix autoscan plugin bindings and poll status (#75)
* fix(autoscan): bind sources by plugin id

* fix(autoscan): skip overlapping source polls
2026-06-07 17:18:55 -04:00
eb6024573e feat(audiobooks): make audiobook libraries first-class catalog items (#73)
* docs(audiobooks): design spec for plugin absorption

Plan to absorb silo-plugin-audiobooks into silo-server as a first-party
feature. Audiobooks land in silo's existing SPA; ABS clients connect
directly. Hard constraints: reuse existing tables (media_items,
media_files, user_watch_progress, user_playback_sessions, people,
item_people, library_collections); only two new tables (abs_sessions,
podcast_feeds) and at most one column add (media_libraries.kind);
silo's main :8080 listener handles ABS Socket.io natively. Out of
scope: audiobook requests flow, smart collections, share links,
external recommender, custom metadata providers, separate audiobook
SPA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 1 (discovery + schema)

First of six sub-plans for the absorption. Six tasks: a discovery
audit that resolves the spec's Risk questions, four idempotent SQL
migrations (abs_sessions, podcast_feeds, media_libraries.kind,
audiobooks.enabled feature flag), and an empty-but-compiling
internal/audiobooks package scaffolded into cmd/silo. Lands as a
strict no-op for users (feature flag defaults to false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): discovery findings for absorption sub-plan 1

Locks schema/code decisions for migrations 139-142 and downstream
sub-plans. Resolves open Risk questions from the absorption design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 139 add abs_sessions table

Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
Lets ABS mobile/desktop apps maintain a device-bound session that
silo's audiobooks/abs handlers will validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): match codebase conventions in migration 139

Lowercases type keywords in the abs_sessions CREATE TABLE body to
match neighboring migrations, fixes the client_version column
alignment, and replaces the misleading "parallel to
jellycompat_sessions" header comment with a more accurate
description of the table's role.

Cosmetic only — the running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 140 add podcast_feeds table

Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
the per-feed refresh interval consumed by the upcoming
podcastfeed.Refresher scheduled task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): uppercase PRIMARY KEY in migration 140

Aligns with the codebase convention (type keywords lowercase,
constraint keywords uppercase) established in migration 139's
post-style-fix form. Cosmetic only — running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(audiobooks): migration 141 no-op for media_folders.type

Sub-plan 1 originally reserved migration 141 to add a 'kind' column to
media_libraries discriminating audiobook/podcast libraries. Discovery
audit (sub-plan 1 Task 1) found that the actual table is media_folders
and it already has a type text NOT NULL column with no CHECK constraint
or enum, so 'audiobooks' and 'podcasts' can be added as future values
without DDL.

Landing this migration as a documented no-op preserves the version
numbering audit trail and pins the decision in git history. The
matching down migration is also a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 142 add audiobooks.enabled flag

Server-settings row that gates the absorbed audiobooks feature.
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
sub-plans branch on this flag and operators flip it to 'true' at
cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scaffold internal/audiobooks package

Empty-but-compiling Service that reads the audiobooks.enabled feature
flag from server_settings. Wired into cmd/silo so the package is
referenced from the binary; no routes mounted, no scheduled tasks
registered, no DB writes. Subsequent sub-plans hang scanner branches,
ABS handlers, Socket.io, podcast refresher, and SPA pages off this
Service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): cosmetic cleanups in scaffolded package

Two pre-emptive cleanups flagged by code review before sub-plan 2
copies the patterns:

  1. Sort the internal/audiobooks import after internal/adminjob in
     cmd/silo/main.go (alphabetical).
  2. Drop the redundant "audiobooks: " prefix from the Enabled() error
     wrap; matches how every other top-level service package
     (watchstate, scanqueue, metadata, etc.) formats errors.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 2 (scanner)

Second of six sub-plans. 10 tasks: PersonKind constants for Author and
Narrator, audio-extension recognizer, library-type helpers, a
walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter
extraction via ffprobe, single-file and multi-file audiobook parsers,
scanner write path producing media_items.type='audiobook', and a
filesystem podcast parser (RSS deferred to sub-plan 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add Author and Narrator PersonKind constants

Discovery audit confirmed item_people.kind is unconstrained smallint
with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook
people-links written by the upcoming scanner branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add audio-extension recognizer for scanner

Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by
upcoming audiobook and podcast scanner branches to filter directory
walks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library-type recognizers for scanner dispatch

isAudiobookLibraryType and isPodcastLibraryType match singular and
plural forms case-insensitively, mirroring isMovieLibraryType. Used by
upcoming scanner walk branches (Task 4) that filter audio files into
audiobook and podcast libraries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(scanner): replace movieLibrary bool with typed walkMode

Lets walkLogicalTree dispatch on multiple library shapes (video, movie,
audiobook, podcast) without proliferating boolean flags. Behavior for
existing video and movie libraries is unchanged; audiobook and podcast
modes will be consumed by the upcoming audiobook.go and podcast.go
parsers in later tasks of this sub-plan.

walkModeFor() derives the mode from a media_folders.type string;
unknown types default to walkModeVideo to preserve prior behavior for
any caller still passing a raw type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose ffprobe format tags on ProbeData

The audiobook scanner needs format-level tags (title, artist, album,
date) for media_items metadata; ffprobe already parses them in
ffprobeFormat.Tags but ProbeData previously discarded them. Add
FormatTags map[string]string to ProbeData, populate it in
convertProbeData via a new normalizeFormatTags helper that lowercases
keys and trims values.

Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and
format tags, and a test that verifies ProbeFile() returns both
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): parser for single-file audiobook folders

parseAudiobookFolder reads tags + chapters via the existing ProbeFile
(now that Task 5 exposes FormatTags on ProbeData) and produces a
parsedAudiobook struct. Title falls back from "title" tag to "album";
author from "artist" -> "album_artist" -> "composer"; series from
"album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools).
Year parsed from "date" or "year" tags, tolerating ISO dates and
parenthesized forms.

Single-file case only; multi-file folders (one audio file per chapter)
return a placeholder error and arrive in Task 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): multi-file audiobook folder support

Folders containing N audio files (one per chapter/part) get one
parsedAudiobookFile per file; each file's chapter list is synthesized
as a single chapter with title = filename stem. Title/author/series/
year come from the first file's tags.

Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in
favor of the existing firstNonEmpty already in probe.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scanner write path produces audiobook media_items

ScanAudiobookFolder walks an audiobooks-typed media folder and treats
each immediate subdirectory as one audiobook. For each parsed audiobook
it upserts:
  - one media_items row with type='audiobook'
  - one media_files row per audio file (with chapters JSONB)
  - author/narrator links in item_people (kind=7, kind=8)

Adds itemRepo and personRepo to the Scanner struct, wired from
fileRepo.Pool() in NewScanner — no constructor signature change needed.

ScanFolder dispatches to this path when folder.Type='audiobooks',
bypassing the per-file movie/TV pipeline because audiobooks are
folder-scoped entities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): filesystem podcast scanner

ScanPodcastFolder walks a podcasts-typed media folder, treating each
subdirectory as a podcast show and each audio file inside as an
episode. Writes media_items.type='podcast' + episodes rows + media_files
rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5;
this task covers filesystem-only ingestion.

ScanFolder dispatches to this path when folder.Type='podcasts'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 5 (podcasts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose audiobooks/podcasts library types in admin UI

Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown
in the admin libraries page so operators can flag a folder as an
audiobook or podcast library. Extends contentLevelsForType() so the
admin UI's downstream filtering treats those types correctly
(audiobook -> ['audiobook'], podcasts -> ['podcast',
'podcast_episode']).

Backend scanner branches for these types were already wired in
sub-plan 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge

origin/main adds 139_media_requests at the same number our local
audiobook branch had used for abs_sessions. Renumber ours to 147 to
free up 139 for the upstream migration. The schema_versions row is
updated in lockstep on the running database so the migrator sees the
abs_sessions migration as already applied at its new version.

Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook
feature flag, abs playback sessions, podcast episode guid, audiobook
series, audiobook title cleanup) stay where they are — they don't
collide with anything on origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge

origin/main added 140_user_permissions at the same version this branch
had used for podcast_feeds. Renumber ours to 157 (next free above the
collections-unify migration at 156) so 140 is free for the upstream
migration. schema_versions on the running database is updated in lockstep
so the migrator sees podcast_feeds as already applied at its new version.

Same pattern as d59c1cb (renumber 139_abs_sessions to 147 for the prior
main merge). Pending migrations after this rename: 132 (downloaded
subtitles admin index, main), 140 (user_permissions, main), and 156
(unify_user_collections, this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge

Same shape as eb8f67d (the 140→157 renumber from the previous main
merge). origin/main added 141_episode_title_sort_index at the same
version this branch had used for media_folders_kind_noop. Renumber
ours to 159 (next free above the audiobook_series truncate at 158) so
141 is open for the upstream migration. schema_versions on the
running database is updated in lockstep so the migrator sees
media_folders_kind_noop as already applied at its new version.

Pending migrations on silo-prod after this rename: 141
(episode_title_sort_index, main) and any other newer ones from main
that the branch hasn't picked up yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge

Companion to 3c6f062's 141 renumber — origin/main also added
142_episode_catalog_entries (alongside 141_episode_title_sort_index)
at a version this branch had used for the audiobooks feature flag.
Renumber ours to 160 so 142 is open for the upstream migration;
schema_versions on silo-prod is updated in lockstep so the migrator
sees audiobooks_feature_flag as already applied at its new version.

This was the only remaining collision (verified by checking for
duplicate version prefixes across migrations/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address foundation review comments

* fix(audiobooks): tighten scanner identity handling

* fix(audiobooks): propagate scanner cancellation

* chore(audiobooks): adopt goose migration layout

* docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP)

Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/
progress), TanStack Query hooks + types, three React pages
(Library/Detail/Player), and navigation integration. Scoped to MVP —
author/series indices, smart collections, share links, and other
nice-to-haves from the spec are deferred. Streaming reuses silo's
existing /api/v1/stream/{session_id}; no new transcode code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): list endpoint at GET /api/v1/audiobooks

Paginated list of media_items with type='audiobook' scoped to the
caller's accessible libraries via the existing access filter.
Mirrors silo's existing list-style handlers for movies and series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}

Returns the media_items row, its media_files (with chapters JSONB),
author/narrator extracted from item_people (kinds 7/8), and the
caller's per-profile listening progress from user_watch_progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress

UPSERTs user_watch_progress for the caller's (user_id, profile_id,
content_id). Body carries position_seconds; clients are expected to
post every 5-10s during playback plus on pause/seek (matching silo's
existing video progress cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): frontend types and TanStack Query hooks

TypeScript types match the JSON shapes from the new
/api/v1/audiobooks endpoints (list, detail, progress). Three hooks:
useAudiobookLibrary (list), useAudiobook (detail), and
useReportAudiobookProgress (mutation that invalidates the detail
query on success so progress updates reflect immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library grid page at /audiobooks

Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.

Routes themselves are wired in Task 8 (navigation integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail page with chapter list

Renders cover, title, author, narrator, year, and overview alongside a
chapter list. Clicking a chapter opens an inline sticky
AudiobookPlayer at that chapter's start. A "Resume" button restarts
playback at the saved progress position if present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): HTML5 audio player with chapter navigation

Single-file audiobook playback for MVP. Multi-file queuing arrives in
a follow-up. Streams via the existing /api/v1/direct-download GET
endpoint. Position is reported to /api/v1/audiobooks/{id}/progress
every 10s while playing plus on pause/seek/end. Skip-30s, playback
rate select, chapter list panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): wire navigation and routes

Adds an Audiobooks entry to the sidebar and registers the two new
routes (/audiobooks for the library grid, /audiobooks/book/:id for
detail). The player renders inline inside the detail page; no
dedicated player route is required for MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address native API review comments

* feat(audiobooks): add ABS compatibility and polish

* fix(audiobooks): stabilize ABS playback progress reporting

* fix(audiobooks): clean up ABS branch review fixes

* chore(audiobooks): adopt goose layout for ABS migrations

* fix(audiobooks): align player seek bar props

* feat(audiobooks): make libraries first-class catalog items

* feat(admin): add server restart endpoint

* fix(audiobooks): address review comment findings

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:57:05 -04:00
zZebrahzandGitHub c469550fa0 fix(libraries): collapse admin library diagnostics (#61)
* Fix troubleshooting visibility on admin libraries

* Remove metadata matcher badge from admin libraries

* test(libraries): cover hidden matcher queue badge
2026-06-07 12:51:45 -04:00
d3v1l1989andGitHub 3353456d46 fix(jellycompat): aggregate series watch state from episode progress (#70)
A series never has a user_watch_progress row of its own, so series items
always rendered Played:false with no UnplayedItemCount — every show looked
unwatched in compat clients' library views, and the IsPlayed browse filter
treated all series as unplayed. Roll episode progress up to series rows in
the browse/search/detail paths, mirroring applySeasonUserData and the
native API's rollup semantics (WatchedCount/UnplayedCount/InProgressCount).
Progress lookups are chunked at 500 ids (per-user stores may be
SQLite-backed) and the rollup is capped at 250 series per page so
max-limit browses of series-only libraries stay bounded.
2026-06-07 11:42:44 -04:00
d3v1l1989andGitHub 826f0d0b9a fix(jellycompat): serve real MediaSources on Resume/NextUp pages (#69)
Infuse and SenPlayer build Continue Watching rows from the listing's
MediaSources and discard responses that carry only the detail-field stub,
leaving the row empty. Keep the progress scan list-level (the per-entry
GetItemDetail fanout removed in the error-report-2026-05-08 §6 fix stays
removed), but re-map the returned page — bounded by the request limit,
capped at 100 — through the detail path when the client requests
detail-level Fields, matching HandleLatest's existing pattern. The stub
remains as the fallback past the cap and is made honest: playability
flags set, collections serialized empty instead of null.
2026-06-07 11:10:15 -04:00
0b3723c7f2 fix(jellycompat): enforce allow_4k_transcode (#65)
* fix(jellycompat): enforce allow_4k_transcode in playback negotiation and transcode start

The compat layer never read the setting, so clients that couldn't direct
play a 4K file got a full video encode regardless. Suppress
SupportsTranscoding for 4K sources during negotiation and guard the local
and remote transcode starts; audio-only transcodes (video copy) stay
allowed, matching the native handler.

Fixes #64

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(jellycompat): cover settings read error in 4K transcode default-deny

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:47:59 -04:00
129 changed files with 4957 additions and 2383 deletions
+9
View File
@@ -1,6 +1,12 @@
# Silo Docker deployment
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
# Linux/macOS example for enabling NVIDIA override:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
# Example: MEDIA_ROOT=/srv/media
MEDIA_ROOT=/srv/media
@@ -40,6 +46,9 @@ POSTGRES_SHM_SIZE=8gb
# PROXY_PORT=8083
# TRANSCODE_PORT=8082
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
# NVIDIA_GPU_COUNT=1
# Run from source / advanced overrides
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
+23
View File
@@ -34,6 +34,29 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
### Optional NVIDIA/NVENC
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
Use the optional override file when you want NVENC:
```sh
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
```
If you want this controlled from `.env`, set `COMPOSE_FILE`:
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
NVIDIA_GPU_COUNT=1
```
Windows uses `;` instead of `:` between compose files.
Then `docker compose up -d` will include the NVIDIA override automatically.
4. **Configure through the admin UI**
Add libraries, users, metadata providers, and playback settings from the web interface.
+32 -48
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io/fs"
@@ -18,6 +17,7 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -425,6 +425,8 @@ func main() {
appCtx, appCancel := context.WithCancel(ctx)
defer appCancel()
restartReqCh := make(chan struct{}, 1)
var restartRequested atomic.Bool
eventBus := cache.NewEventBus(cfg.Redis.URL)
logStreamHub := logstream.NewHub(nodeID, eventBus)
@@ -525,14 +527,20 @@ func main() {
OpsLogRepo: opsRepo,
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
RequestServerRestart: func(context.Context) error {
if !restartRequested.CompareAndSwap(false, true) {
return handlers.ErrServerRestartAlreadyRequested
}
restartReqCh <- struct{}{}
return nil
},
}
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
audiobooksEnabled, err := audiobooksService.Enabled(appCtx)
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
slog.Warn("audiobooks feature disabled; failed to read setting", "err", err)
audiobooksEnabled = false
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
absCompatEnabled = false
}
deps.AudiobooksEnabled = audiobooksEnabled
adminJobCancelRegistry := adminjob.NewCancelRegistry()
deps.AdminJobCancelRegistry = adminJobCancelRegistry
if needsWorkers && deps.DB != nil {
@@ -1490,6 +1498,9 @@ func main() {
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
autoscanRepo := autoscan.NewRepository(deps.DB)
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
}
autoscanSvc := api.BuildAutoscanService(
autoscanRepo,
pluginService,
@@ -1519,7 +1530,7 @@ func main() {
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
if audiobooksEnabled && audiobookEnricher != nil {
if audiobookEnricher != nil {
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
@@ -1542,7 +1553,7 @@ func main() {
// available. Routes are mounted at the root level by NewRouter (not under
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
// /abs/socket.io/* without path prefix hacks.
if audiobooksEnabled && deps.DB != nil {
if absCompatEnabled && deps.DB != nil {
absUserRepo := auth.NewUserRepository(deps.DB)
absSessionRepo := auth.NewSessionRepository(deps.DB)
absJWTService := auth.NewJWTService(
@@ -1884,47 +1895,17 @@ func main() {
provider := auth.NewLocalProvider(userRepo, sessionRepo)
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
// Access filter resolver for profile-scoped library access.
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
if userStoreProvider == nil {
return catalog.AccessFilter{}
}
store, err := userStoreProvider.ForUser(ctx, userID)
if err != nil {
return catalog.AccessFilter{}
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil || profile == nil {
return catalog.AccessFilter{}
}
filter := catalog.AccessFilter{
MaxContentRating: profile.MaxContentRating,
}
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
}
// Apply user-disabled library IDs.
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
var disabled []int
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
if filter.AllowedLibraryIDs != nil {
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
disSet := make(map[int]struct{}, len(disabled))
for _, id := range disabled {
disSet[id] = struct{}{}
}
for _, id := range filter.AllowedLibraryIDs {
if _, ok := disSet[id]; !ok {
filtered = append(filtered, id)
}
}
filter.AllowedLibraryIDs = filtered
} else {
filter.DisabledLibraryIDs = disabled
}
}
}
return filter
// Access filter resolver for viewer-scoped library access.
// Backed by the shared access.Resolver so account-level library
// restrictions (users.library_ids), profile restrictions,
// user-disabled libraries, and rating/quality ceilings apply to
// the compat API exactly as they do to the native API.
if userStoreProvider != nil {
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
userRepo,
userStoreProvider,
nil, // profile tokens unused: compat login already verifies PINs
))
}
}
@@ -2016,6 +1997,9 @@ func main() {
case sig := <-sigCh:
appCancel()
slog.Info("received signal, shutting down", "signal", sig)
case <-restartReqCh:
appCancel()
slog.Info("server restart requested, shutting down")
case serverErr := <-errCh:
appCancel()
slog.Error("server error, shutting down", "error", serverErr)
+20
View File
@@ -0,0 +1,20 @@
services:
silo:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: ${NVIDIA_GPU_COUNT:-1}
capabilities: [gpu]
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-transcode:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: ${NVIDIA_GPU_COUNT:-1}
# capabilities: [gpu]
+1 -1
View File
@@ -46,7 +46,7 @@ services:
- "${ABS_PORT:-13378}:13378"
volumes:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
+5 -15
View File
@@ -64,8 +64,8 @@ type PluginScanSourceAdapter struct {
Svc *plugins.Service
}
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
}
// scanSourceCapabilityType is the plugin capability type autoscan discovery
@@ -75,15 +75,6 @@ const scanSourceCapabilityType = "scan_source.v1"
// PluginScanSourceLister adapts the plugin installation store to
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
// capability across ALL installed plugins, regardless of enabled state.
//
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
// disabled plugins here, PollOnce would prune their sources and skip them with no
// last_error — they'd vanish silently. Keeping them present means PollOnce still
// attempts them; the plugin client fails to load (not running) and the source
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
// List entirely) is a true orphan. The Add-source picker shares this all-installed
// set, which is fine — operators can bind sources against an installed capability.
type PluginScanSourceLister struct {
Store *plugins.InstallationStore
}
@@ -104,10 +95,9 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
continue
}
out = append(out, autoscan.DiscoveredSource{
InstallationID: c.InstallationID,
CapabilityID: c.ID,
PluginID: inst.PluginID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
PluginID: inst.PluginID,
CapabilityID: c.ID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
})
}
}
-531
View File
@@ -1,531 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
)
// HandleGetAudiobook serves GET /api/v1/audiobooks/{id}. Returns the
// audiobook media_item, its media_files (with embedded chapters),
// author/narrator from item_people (kinds 7/8), and the caller's
// per-profile listening progress.
func (h *AudiobookHandler) HandleGetAudiobook(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load audiobook failed")
return
}
if len(items) == 0 {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
item := items[0]
if item == nil || item.Type != "audiobook" {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
files, err := h.Files.GetByContentID(r.Context(), contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
return
}
author, narrator, err := h.fetchAuthorNarrator(r, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load people failed")
return
}
progress := h.fetchProgress(r, contentID)
alsoByAuthor := h.fetchAlsoByAuthor(r.Context(), contentID)
inSeries := h.fetchInSeries(r.Context(), contentID)
similar := h.fetchSimilar(r.Context(), contentID)
otherNarrations := h.fetchOtherNarrations(r.Context(), contentID)
resp := audiobookDetailResponse{
Audiobook: audiobookDetailItem{
ContentID: item.ContentID,
Title: item.Title,
Year: item.Year,
Overview: item.Overview,
PosterURL: h.presignAudiobookPoster(r.Context(), item.PosterPath),
Publisher: pickFirstString(item.Studios),
Genres: append([]string(nil), item.Genres...),
},
Author: author,
Narrator: narrator,
Files: audiobookDetailFiles(files),
Progress: progress,
AlsoByAuthor: alsoByAuthor,
InSeries: inSeries,
SimilarAudiobooks: similar,
OtherNarrations: otherNarrations,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// fetchAuthorNarrator retrieves the first author (kind=7) and narrator (kind=8)
// name from item_people for the given content ID.
func (h *AudiobookHandler) fetchAuthorNarrator(r *http.Request, contentID string) (author, narrator string, err error) {
people, err := h.Items.GetPeople(r.Context(), contentID)
if err != nil {
return "", "", err
}
for _, p := range people {
switch p.Kind {
case models.PersonKindAuthor:
if author == "" {
author = p.Name
}
case models.PersonKindNarrator:
if narrator == "" {
narrator = p.Name
}
}
if author != "" && narrator != "" {
break
}
}
return author, narrator, nil
}
// fetchAlsoByAuthor returns other audiobooks sharing any author (item_people
// kind=PersonKindAuthor) with the given book. Capped at 12 to keep the rail
// snappy on the client. Returns an empty slice when there is no author or
// no other matching titles.
func (h *AudiobookHandler) fetchAlsoByAuthor(ctx context.Context, contentID string) []audiobookRelatedItem {
if h.Files == nil {
return nil
}
const q = `
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
FROM item_people ip1
JOIN item_people ip2
ON ip2.person_id = ip1.person_id
AND ip2.kind = ip1.kind
AND ip2.content_id <> ip1.content_id
JOIN media_items m2
ON m2.content_id = ip2.content_id
AND m2.type = 'audiobook'
WHERE ip1.content_id = $1
AND ip1.kind = $2
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
LIMIT 12
`
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
if err != nil {
return nil
}
defer rows.Close()
out := make([]audiobookRelatedItem, 0, 12)
seen := make(map[string]struct{})
for rows.Next() {
var it audiobookRelatedItem
var poster string
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
return nil
}
if _, dup := seen[it.ContentID]; dup {
continue
}
seen[it.ContentID] = struct{}{}
it.PosterURL = h.presignPoster(ctx, poster)
out = append(out, it)
}
return out
}
// fetchSimilar returns the "You might also like" rail. Prefers embedding-based
// nearest-neighbor search when the source book has an embedding; falls back to
// shared-genre matching otherwise (book is brand new, embedding job hasn't run,
// or the embedding lookup errored). The genre fallback excludes same-author
// books so the rail complements rather than duplicates also_by_author.
func (h *AudiobookHandler) fetchSimilar(ctx context.Context, contentID string) []audiobookRelatedItem {
if h.Recs != nil {
if items := h.fetchSimilarByEmbedding(ctx, contentID); items != nil {
return items
}
}
return h.fetchSimilarByGenres(ctx, contentID)
}
// fetchSimilarByEmbedding returns nearest-neighbor audiobooks by cosine
// similarity on the gemini embedding vector. Returns nil (not an empty slice)
// when the source book has no embedding yet, so the caller can fall back to
// the genre-based recommender.
func (h *AudiobookHandler) fetchSimilarByEmbedding(ctx context.Context, contentID string) []audiobookRelatedItem {
if h.Recs == nil || h.Files == nil {
return nil
}
emb, err := h.Recs.GetEmbedding(ctx, contentID)
if err != nil || emb == nil {
return nil
}
// Pull extra candidates so we can drop the source book's own author from
// the rail (they belong in also_by_author).
scored, err := h.Recs.FindSimilar(ctx, emb, []string{contentID}, "audiobook", 36)
if err != nil || len(scored) == 0 {
return nil
}
ids := make([]string, 0, len(scored))
for _, s := range scored {
ids = append(ids, s.MediaItemID)
}
const q = `
WITH this_author AS (
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
)
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
FROM media_items m
WHERE m.content_id = ANY($3)
AND m.type = 'audiobook'
AND NOT EXISTS (
SELECT 1 FROM item_people ip
WHERE ip.content_id = m.content_id
AND ip.kind = $2
AND ip.person_id IN (SELECT person_id FROM this_author)
)
`
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor, ids)
if err != nil {
return nil
}
defer rows.Close()
byID := make(map[string]audiobookRelatedItem, len(scored))
for rows.Next() {
var it audiobookRelatedItem
var poster string
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
return nil
}
it.PosterURL = h.presignPoster(ctx, poster)
byID[it.ContentID] = it
}
out := make([]audiobookRelatedItem, 0, 12)
for _, s := range scored {
if it, ok := byID[s.MediaItemID]; ok {
out = append(out, it)
if len(out) >= 12 {
break
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// fetchOtherNarrations returns sibling audiobook items that share the same
// "core" title (after stripping the common "read by X" / "(Read by X)" /
// "(UK Version: Read by X)" suffixes) and the same author. Powers the
// narrator picker dropdown on the detail page when a single book has
// multiple narration editions stored as separate items in the catalog.
func (h *AudiobookHandler) fetchOtherNarrations(ctx context.Context, contentID string) []audiobookNarration {
if h.Files == nil {
return nil
}
// The regex strips a trailing block of the form "[ separator] [( ]read by ...[) ]".
// Tested against patterns in the corpus: "Foo Read By X",
// "Foo - read by X", "Foo (Read by X)", "Foo (UK Version: Read by X)".
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
const q = `
WITH this_book AS (
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
FROM media_items WHERE content_id = $2
),
this_author AS (
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
)
SELECT
m.content_id,
m.title,
COALESCE(MAX(p.name), '') AS narrator,
COALESCE(m.year, 0)
FROM media_items m
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
LEFT JOIN people p ON p.id = ipn.person_id
WHERE m.type = 'audiobook'
AND m.content_id <> $2
AND trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)
AND EXISTS (
SELECT 1 FROM item_people ip
WHERE ip.content_id = m.content_id
AND ip.kind = $3
AND ip.person_id IN (SELECT person_id FROM this_author)
)
GROUP BY m.content_id, m.title, m.year
ORDER BY m.year DESC NULLS LAST, m.title
LIMIT 8
`
rows, err := h.Files.Pool().Query(ctx, q,
stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator)
if err != nil {
return nil
}
defer rows.Close()
out := make([]audiobookNarration, 0, 4)
for rows.Next() {
var n audiobookNarration
if err := rows.Scan(&n.ContentID, &n.Title, &n.Narrator, &n.Year); err != nil {
return nil
}
out = append(out, n)
}
if len(out) == 0 {
return nil
}
return out
}
// fetchSimilarByGenres returns up to 12 audiobooks sharing genres with the
// given book, excluding books by the same author so the rail complements
// rather than duplicates also_by_author. Used as a fallback when no
// embedding exists for the source yet.
func (h *AudiobookHandler) fetchSimilarByGenres(ctx context.Context, contentID string) []audiobookRelatedItem {
if h.Files == nil {
return nil
}
const q = `
WITH this_genres AS (
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
),
this_author AS (
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
)
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
FROM media_items m
WHERE m.type = 'audiobook'
AND m.content_id <> $1
AND m.genres && (SELECT array_agg(g) FROM this_genres)
AND NOT EXISTS (
SELECT 1 FROM item_people ip
WHERE ip.content_id = m.content_id
AND ip.kind = $2
AND ip.person_id IN (SELECT person_id FROM this_author)
)
ORDER BY
cardinality(ARRAY(SELECT unnest(m.genres) INTERSECT SELECT g FROM this_genres)) DESC,
COALESCE(m.year, 0) DESC,
LOWER(m.sort_title)
LIMIT 12
`
rows, err := h.Files.Pool().Query(ctx, q, contentID, models.PersonKindAuthor)
if err != nil {
return nil
}
defer rows.Close()
out := make([]audiobookRelatedItem, 0, 12)
for rows.Next() {
var it audiobookRelatedItem
var poster string
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster); err != nil {
return nil
}
it.PosterURL = h.presignPoster(ctx, poster)
out = append(out, it)
}
return out
}
// fetchInSeries returns the series the audiobook belongs to (if any) along
// with the ordered list of sibling entries. Driven by the audiobook_series
// table populated by either the scanner (tag-derived) or migration 145's
// best-effort title-pattern backfill.
func (h *AudiobookHandler) fetchInSeries(ctx context.Context, contentID string) *audiobookSeriesGroup {
if h.Files == nil {
return nil
}
const q = `
SELECT
m.content_id,
m.title,
COALESCE(m.year, 0),
COALESCE(m.poster_path, ''),
s.series_name,
s.series_index
FROM audiobook_series root
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
JOIN media_items m ON m.content_id = s.content_id AND m.type = 'audiobook'
WHERE root.content_id = $1
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
LIMIT 30
`
rows, err := h.Files.Pool().Query(ctx, q, contentID)
if err != nil {
return nil
}
defer rows.Close()
var seriesName string
entries := make([]audiobookRelatedItem, 0, 16)
for rows.Next() {
var (
it audiobookRelatedItem
poster string
thisName string
idxNum *float64
)
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &poster, &thisName, &idxNum); err != nil {
return nil
}
if seriesName == "" {
seriesName = thisName
}
if idxNum != nil {
n := int(*idxNum)
if float64(n) == *idxNum {
it.SeriesIndex = &n
}
}
it.PosterURL = h.presignPoster(ctx, poster)
entries = append(entries, it)
}
// A series with only this book is not useful as a rail.
if len(entries) < 2 {
return nil
}
return &audiobookSeriesGroup{Name: seriesName, Entries: entries}
}
// fetchProgress returns the caller's listening progress for this audiobook, or
// nil when no progress exists or the caller is not authenticated with a profile.
func (h *AudiobookHandler) fetchProgress(r *http.Request, contentID string) *audiobookDetailProgress {
if h.StoreProvider == nil {
return nil
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
return nil
}
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if profileID == "" {
return nil
}
store, err := h.StoreProvider.ForUser(r.Context(), userID)
if err != nil || store == nil {
return nil
}
wp, err := store.GetProgress(r.Context(), profileID, contentID)
if err != nil || wp == nil {
return nil
}
return &audiobookDetailProgress{
PositionSeconds: wp.PositionSeconds,
Completed: wp.Completed,
UpdatedAt: wp.UpdatedAt,
}
}
type audiobookDetailResponse struct {
Audiobook audiobookDetailItem `json:"audiobook"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
Files []audiobookDetailFile `json:"files"`
Progress *audiobookDetailProgress `json:"progress,omitempty"`
AlsoByAuthor []audiobookRelatedItem `json:"also_by_author,omitempty"`
InSeries *audiobookSeriesGroup `json:"in_series,omitempty"`
SimilarAudiobooks []audiobookRelatedItem `json:"similar_audiobooks,omitempty"`
OtherNarrations []audiobookNarration `json:"other_narrations,omitempty"`
}
// audiobookNarration is a sibling narration (same book, different
// narrator/edition). Returned alongside the primary item so the UI can
// surface a narrator picker.
type audiobookNarration struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Narrator string `json:"narrator,omitempty"`
Year int `json:"year,omitempty"`
}
type audiobookRelatedItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
SeriesIndex *int `json:"series_index,omitempty"`
}
type audiobookSeriesGroup struct {
Name string `json:"name,omitempty"`
Entries []audiobookRelatedItem `json:"entries"`
}
type audiobookDetailItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
Publisher string `json:"publisher,omitempty"`
Genres []string `json:"genres,omitempty"`
}
func (h *AudiobookHandler) presignPoster(ctx context.Context, path string) string {
if path == "" || h.Detail == nil {
return ""
}
return h.Detail.PresignURL(ctx, path, "featured")
}
func pickFirstString(values []string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
type audiobookDetailFile struct {
ID int `json:"id"`
Path string `json:"path"`
DurationSeconds int `json:"duration_seconds"`
Container string `json:"container,omitempty"`
CodecAudio string `json:"codec_audio,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
AudioChannels int `json:"audio_channels,omitempty"`
Chapters []models.MediaChapter `json:"chapters,omitempty"`
}
type audiobookDetailProgress struct {
PositionSeconds float64 `json:"position_seconds"`
Completed bool `json:"completed"`
UpdatedAt string `json:"updated_at"`
}
func audiobookDetailFiles(files []*models.MediaFile) []audiobookDetailFile {
out := make([]audiobookDetailFile, 0, len(files))
for _, f := range files {
out = append(out, audiobookDetailFile{
ID: f.ID,
Path: f.FilePath,
DurationSeconds: f.Duration,
Container: f.Container,
CodecAudio: f.CodecAudio,
Bitrate: f.Bitrate,
AudioChannels: f.AudioChannels,
Chapters: f.Chapters,
})
}
return out
}
-183
View File
@@ -1,183 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// AudiobookHandler serves silo-native audiobook endpoints under
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
// sub-plan 4's ABS-compat layer is separate.
type AudiobookHandler struct {
Items *catalog.ItemRepository
Files *scanner.FileRepository
StoreProvider userstore.UserStoreProvider
Detail *catalog.DetailService
// Recs is optional. When set, the detail handler uses embedding-based
// nearest-neighbor search for the "You might also like" rail and falls
// back to shared-genre matching only when no embedding exists for the
// source book yet.
Recs *recommendations.Repo
}
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
// Paginated catalog of media_items with type='audiobook' scoped to the
// caller's accessible libraries.
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
ctx := r.Context()
access := requestAccessFilter(r)
genre := r.URL.Query().Get("genre")
conditions, args, argIdx, empty := audiobookListConditions(access, genre)
if empty {
writeAudiobookListResponse(w, nil, 0, limit, offset)
return
}
pool := h.Files.Pool()
var total int
countSQL := "SELECT COUNT(*) FROM media_items mi WHERE " + strings.Join(conditions, " AND ")
if err := pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks count failed")
return
}
dataArgs := append([]any(nil), args...)
limitIdx := argIdx
dataArgs = append(dataArgs, limit)
offsetIdx := argIdx + 1
dataArgs = append(dataArgs, offset)
rows, err := pool.Query(ctx, fmt.Sprintf(`
SELECT
mi.content_id,
mi.title,
COALESCE(mi.year, 0),
COALESCE(mi.poster_path, ''),
COALESCE(SUM(mf.duration), 0)::int AS duration_seconds,
COALESCE(MIN(mf.container), '') AS container,
COALESCE(MIN(mf.codec_audio), '') AS codec_audio,
COUNT(mf.id) AS file_count
FROM media_items mi
LEFT JOIN media_files mf ON mf.content_id = mi.content_id
WHERE %s
GROUP BY mi.content_id, mi.title, mi.year, mi.poster_path, mi.sort_title
ORDER BY LOWER(mi.sort_title), LOWER(mi.title)
LIMIT $%d OFFSET $%d
`, strings.Join(conditions, " AND "), limitIdx, offsetIdx), dataArgs...)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
return
}
defer rows.Close()
items := make([]audiobookListItem, 0, limit)
for rows.Next() {
var it audiobookListItem
var posterPath string
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &posterPath, &it.DurationSeconds, &it.Container, &it.CodecAudio, &it.FileCount); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "scan audiobook row failed")
return
}
it.PosterURL = h.presignAudiobookPoster(ctx, posterPath)
items = append(items, it)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "iterate audiobook rows failed")
return
}
writeAudiobookListResponse(w, items, total, limit, offset)
}
func audiobookListConditions(access catalog.AccessFilter, genre string) ([]string, []any, int, bool) {
conditions := []string{"mi.type = 'audiobook'"}
var args []any
argIdx := 1
if genre != "" {
conditions = append(conditions, fmt.Sprintf("$%d = ANY(mi.genres)", argIdx))
args = append(args, genre)
argIdx++
}
if access.AllowedLibraryIDs != nil {
if len(access.AllowedLibraryIDs) == 0 {
return nil, nil, 1, true
}
conditions = append(conditions, fmt.Sprintf(`
EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id
AND mil.media_folder_id = ANY($%d)
)`, argIdx))
args = append(args, access.AllowedLibraryIDs)
argIdx++
}
if len(access.DisabledLibraryIDs) > 0 {
if access.AllowedLibraryIDs == nil {
conditions = append(conditions, `
EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id
)`)
}
conditions = append(conditions, fmt.Sprintf(`
NOT EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id
AND mil.media_folder_id = ANY($%d)
)`, argIdx))
args = append(args, access.DisabledLibraryIDs)
argIdx++
}
catalog.ApplySectionAccessFilter("mi", catalog.AccessFilter{MaxContentRating: access.MaxContentRating}, &conditions, &args, &argIdx)
return conditions, args, argIdx, false
}
func writeAudiobookListResponse(w http.ResponseWriter, items []audiobookListItem, total, limit, offset int) {
if items == nil {
items = []audiobookListItem{}
}
resp := struct {
Items []audiobookListItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}{Items: items, Total: total, Limit: limit, Offset: offset}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
type audiobookListItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year"`
PosterURL string `json:"poster_url,omitempty"`
DurationSeconds int `json:"duration_seconds"`
Container string `json:"container,omitempty"`
CodecAudio string `json:"codec_audio,omitempty"`
FileCount int `json:"file_count"`
}
func (h *AudiobookHandler) presignAudiobookPoster(ctx context.Context, path string) string {
if h == nil || h.Detail == nil || path == "" {
return ""
}
return h.Detail.PresignURL(ctx, cardThumbnailPath(path), "card")
}
@@ -1,113 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type audiobookProgressRequest struct {
PositionSeconds float64 `json:"position_seconds"`
MediaFileID int `json:"media_file_id,omitempty"`
}
// HandleReportAudiobookProgress serves POST /api/v1/audiobooks/{id}/progress.
// UPSERTs into user_watch_progress for the caller's (user_id, profile_id, content_id).
func (h *AudiobookHandler) HandleReportAudiobookProgress(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "id is required")
return
}
var req audiobookProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid request body")
return
}
if req.PositionSeconds < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "position_seconds must be >= 0")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if userID == 0 || profileID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
if h.Items == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "catalog unavailable")
return
}
items, err := h.Items.GetByIDsWithAccess(r.Context(), []string{contentID}, requestAccessFilter(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "check access failed")
return
}
if len(items) == 0 || items[0] == nil || items[0].Type != "audiobook" {
writeError(w, http.StatusNotFound, "not_found", "audiobook not found")
return
}
durationSeconds, err := h.audiobookProgressDuration(r.Context(), contentID, req.MediaFileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "load files failed")
return
}
if h.StoreProvider == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
return
}
store, err := h.StoreProvider.ForUser(r.Context(), userID)
if err != nil || store == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "store unavailable")
return
}
if err := store.SetProgress(r.Context(), profileID, contentID, req.PositionSeconds, durationSeconds, userstore.ProgressThresholds{}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "save progress failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AudiobookHandler) audiobookProgressDuration(ctx context.Context, contentID string, mediaFileID int) (float64, error) {
if h == nil || h.Files == nil {
return 0, nil
}
files, err := h.Files.GetByContentID(ctx, contentID)
if err != nil {
return 0, err
}
return audiobookDurationForProgress(files, mediaFileID), nil
}
func audiobookDurationForProgress(files []*models.MediaFile, mediaFileID int) float64 {
var total int
for _, f := range files {
if f == nil || f.Duration <= 0 {
continue
}
if mediaFileID > 0 && f.ID == mediaFileID {
return float64(f.Duration)
}
total += f.Duration
}
if total <= 0 {
return 0
}
return float64(total)
}
-61
View File
@@ -1,61 +0,0 @@
package handlers
import (
"context"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestPresignAudiobookPosterDoesNotExposeRawKeysWithoutPresigner(t *testing.T) {
h := &AudiobookHandler{}
got := h.presignAudiobookPoster(context.Background(), "metadata/audiobooks/book-1/poster/original.webp")
if got != "" {
t.Fatalf("PosterURL = %q, want empty without presigner", got)
}
}
func TestAudiobookListConditionsIncludeAccessPredicates(t *testing.T) {
conditions, args, _, empty := audiobookListConditions(catalog.AccessFilter{
AllowedLibraryIDs: []int{1, 2},
DisabledLibraryIDs: []int{9},
MaxContentRating: "PG-13",
}, "Fantasy")
if empty {
t.Fatal("empty = true, want false")
}
sql := strings.Join(conditions, " AND ")
for _, want := range []string{
"mi.type = 'audiobook'",
"ANY(mi.genres)",
"EXISTS",
"NOT EXISTS",
"mi.content_rating = ANY",
} {
if !strings.Contains(sql, want) {
t.Fatalf("conditions missing %q in:\n%s", want, sql)
}
}
if len(args) != 4 {
t.Fatalf("len(args) = %d, want 4", len(args))
}
}
func TestAudiobookDurationForProgress(t *testing.T) {
files := []*models.MediaFile{
{ID: 10, Duration: 120},
{ID: 11, Duration: 180},
}
if got := audiobookDurationForProgress(files, 10); got != 120 {
t.Fatalf("selected file duration = %v, want 120", got)
}
if got := audiobookDurationForProgress(files, 0); got != 300 {
t.Fatalf("total duration = %v, want 300", got)
}
if got := audiobookDurationForProgress([]*models.MediaFile{{ID: 1}}, 1); got != 0 {
t.Fatalf("unknown duration = %v, want 0", got)
}
}
+94 -57
View File
@@ -36,6 +36,7 @@ type autoscanStore interface {
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
LatestEventAt(ctx context.Context) (*time.Time, error)
}
@@ -309,7 +310,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
// credentials: the connection link is by id only.
type autoscanSourceResponse struct {
ID string `json:"id"`
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
@@ -331,7 +332,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
config := normalizeSourceConfig(s.SourceConfig)
return autoscanSourceResponse{
ID: s.ID,
InstallationID: s.InstallationID,
PluginID: s.PluginID,
CapabilityID: s.CapabilityID,
ConnectionID: s.ConnectionID,
Enabled: s.Enabled,
@@ -364,10 +365,9 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
// --- Available scan-source plugins (Add-source picker) ---
type autoscanScanSourcePluginResponse struct {
InstallationID int `json:"installation_id"`
CapabilityID string `json:"capability_id"`
PluginID string `json:"plugin_id"`
DisplayName string `json:"display_name"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
}
// HandleListAvailableScanSources returns every installed scan_source capability
@@ -381,10 +381,9 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
for _, a := range available {
out = append(out, autoscanScanSourcePluginResponse{
InstallationID: a.InstallationID,
CapabilityID: a.CapabilityID,
PluginID: a.PluginID,
DisplayName: a.DisplayName,
PluginID: a.PluginID,
CapabilityID: a.CapabilityID,
DisplayName: a.DisplayName,
})
}
writeJSON(w, http.StatusOK, struct {
@@ -394,12 +393,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
// --- Create source ---
// autoscanCreateSourceInput is the create payload: it names the installed
// scan_source capability to bind (installation_id + capability_id) plus the
// initial binding/scheduling fields. Unlike the update payload, identity is
// supplied here rather than read from an existing row.
// autoscanCreateSourceInput is the create payload: it names the scan_source
// capability to bind (plugin_id + capability_id) plus the initial
// binding/scheduling fields. Unlike the update payload, identity is supplied
// here rather than read from an existing row.
type autoscanCreateSourceInput struct {
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
@@ -420,6 +419,11 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
return
}
capID := strings.TrimSpace(in.CapabilityID)
pluginID := strings.TrimSpace(in.PluginID)
if pluginID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
return
}
if capID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
return
@@ -432,7 +436,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Validate the (installation_id, capability_id) is a currently-installed
// Validate the (plugin_id, capability_id) is a currently-installed
// scan_source capability — a source may only be created against an installed
// plugin capability.
available, err := h.svc.ListAvailableScanSources(r.Context())
@@ -440,13 +444,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeAutoscanError(w, err)
return
}
if !scanSourceInstalled(available, in.InstallationID, capID) {
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
if !scanSourceInstalled(available, pluginID, capID) {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
return
}
connArg := normalizeConnectionID(in.ConnectionID)
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
InstallationID: in.InstallationID,
PluginID: pluginID,
CapabilityID: capID,
ConnectionID: connArg,
Enabled: in.Enabled,
@@ -462,18 +466,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusCreated, sourceResponse(created))
}
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
// set of currently-installed scan_source capabilities.
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
for _, a := range available {
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
return true
}
}
return false
}
// autoscanSourceInput is the source write payload. The (installation_id,
// autoscanSourceInput is the source write payload. The (plugin_id,
// capability_id) identity is read from the existing source row, so only the
// schedulable/binding fields are accepted.
//
@@ -548,7 +552,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
// connection_id is a full-state field: nil means unbind, a UUID string
// means bind. A whitespace-only string is normalised to nil (unbound).
connArg := normalizeConnectionID(in.ConnectionID)
// Update by id; identity (installation_id, capability_id) is immutable and
// Update by id; identity (plugin_id, capability_id) is immutable and
// preserved by the repo. A missing source maps to 404.
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
ID: id,
@@ -581,9 +585,8 @@ func normalizeConnectionID(in *string) *string {
}
// HandleDeleteSource removes a source row. This is the operator's escape hatch
// for an orphaned source (one whose scan_source plugin was uninstalled): the
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
// id maps to 404 via autoscan.ErrNotFound.
// for an orphaned source (one whose scan_source plugin was uninstalled or no
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
@@ -710,7 +713,7 @@ type autoscanEventScanRunResponse struct {
type autoscanEventResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
@@ -753,7 +756,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
return autoscanEventResponse{
ID: e.ID,
SourceID: e.SourceID,
InstallationID: e.InstallationID,
PluginID: e.PluginID,
CapabilityID: e.CapabilityID,
StartedAt: e.StartedAt,
CompletedAt: e.CompletedAt,
@@ -794,9 +797,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
filter.Offset = offset
}
switch filter.Status {
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
default:
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
return
}
@@ -835,7 +838,7 @@ type autoscanScanResponse struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
SourceID *string `json:"source_id,omitempty"`
InstallationID *int `json:"installation_id,omitempty"`
PluginID string `json:"plugin_id,omitempty"`
CapabilityID string `json:"capability_id,omitempty"`
EventStatus string `json:"event_status,omitempty"`
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
@@ -862,7 +865,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
CompletedAt: scan.CompletedAt,
AutoscanEventID: scan.AutoscanEventID,
SourceID: scan.SourceID,
InstallationID: scan.InstallationID,
PluginID: scan.PluginID,
CapabilityID: scan.CapabilityID,
EventStatus: string(scan.EventStatus),
EventCompletedAt: scan.EventCompletedAt,
@@ -923,24 +926,35 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
// --- Status ---
type autoscanStatusSource struct {
ID string `json:"id"`
InstallationID int `json:"installation_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
Label string `json:"label"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
ID string `json:"id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
Label string `json:"label"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
}
type autoscanRunningPollResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
ElapsedMS int64 `json:"elapsed_ms"`
MarkerBefore *string `json:"marker_before,omitempty"`
}
type autoscanStatusResponse struct {
Enabled bool `json:"enabled"`
Sources []autoscanStatusSource `json:"sources"`
ActiveScans int `json:"active_scans"`
AcceptedScans int `json:"accepted_scans"`
RunningScans int `json:"running_scans"`
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
Enabled bool `json:"enabled"`
Sources []autoscanStatusSource `json:"sources"`
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
ActiveScans int `json:"active_scans"`
AcceptedScans int `json:"accepted_scans"`
RunningScans int `json:"running_scans"`
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
}
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
@@ -960,6 +974,11 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
writeAutoscanError(w, err)
return
}
runningEvents, err := h.repo.ListRunningEvents(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
latestEventAt, err := h.repo.LatestEventAt(ctx)
if err != nil {
writeAutoscanError(w, err)
@@ -972,20 +991,38 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
rewrites = []autoscan.PathRewrite{}
}
trimmed = append(trimmed, autoscanStatusSource{
ID: src.ID,
InstallationID: src.InstallationID,
CapabilityID: src.CapabilityID,
ConnectionID: src.ConnectionID,
Enabled: src.Enabled,
Label: src.Label,
PathRewrites: rewrites,
LastRunAt: src.LastRunAt,
LastError: src.LastError,
ID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
ConnectionID: src.ConnectionID,
Enabled: src.Enabled,
Label: src.Label,
PathRewrites: rewrites,
LastRunAt: src.LastRunAt,
LastError: src.LastError,
})
}
now := time.Now()
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
for _, event := range runningEvents {
elapsed := now.Sub(event.StartedAt)
if elapsed < 0 {
elapsed = 0
}
runningPolls = append(runningPolls, autoscanRunningPollResponse{
ID: event.ID,
SourceID: event.SourceID,
PluginID: event.PluginID,
CapabilityID: event.CapabilityID,
StartedAt: event.StartedAt,
ElapsedMS: elapsed.Milliseconds(),
MarkerBefore: event.MarkerBefore,
})
}
writeJSON(w, http.StatusOK, autoscanStatusResponse{
Enabled: s.Enabled,
Sources: trimmed,
RunningPolls: runningPolls,
ActiveScans: queue.Active,
AcceptedScans: queue.Accepted,
RunningScans: queue.Running,
+52 -22
View File
@@ -31,6 +31,7 @@ type fakeAutoscanStore struct {
countScansFn func(autoscan.ScanListFilter) (int, error)
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
countEventsFn func(autoscan.EventListFilter) (int, error)
listRunningFn func() ([]autoscan.Event, error)
queueSummaryFn func() (autoscan.QueueSummary, error)
latestEventAtFn func() (*time.Time, error)
}
@@ -140,6 +141,13 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
return 0, nil
}
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
if f.listRunningFn != nil {
return f.listRunningFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
if f.queueSummaryFn != nil {
return f.queueSummaryFn()
@@ -273,7 +281,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
store := &fakeAutoscanStore{
listSourcesFn: func() ([]autoscan.Source, error) {
listed = true
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
@@ -300,7 +308,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
@@ -331,19 +339,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr"},
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
t.Fatalf("unexpected created source: %+v", got)
}
var body autoscanSourceResponse
@@ -363,14 +371,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
return s, nil
},
}
// Installed set does NOT include (1, "arr").
// Installed set does NOT include ("silo.autoscan.arr", "arr").
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 2, CapabilityID: "other"},
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
@@ -394,12 +402,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr"},
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
@@ -615,7 +623,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -641,7 +649,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -669,7 +677,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with no connection.
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -697,7 +705,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with a connection already bound.
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -727,7 +735,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -925,7 +933,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -990,7 +998,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
upserted := false
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
upserted = true
@@ -1020,7 +1028,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
eventCompleted := completed.Add(-1 * time.Second)
eventID := int64(42)
sourceID := "src-1"
installationID := 7
pluginID := "silo.autoscan.cephfs"
var gotFilter autoscan.ScanListFilter
var gotCountFilter autoscan.ScanListFilter
store := &fakeAutoscanStore{
@@ -1040,7 +1048,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
},
AutoscanEventID: &eventID,
SourceID: &sourceID,
InstallationID: &installationID,
PluginID: pluginID,
CapabilityID: "cephfs",
EventStatus: autoscan.EventStatusSuccess,
EventCompletedAt: &eventCompleted,
@@ -1084,7 +1092,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
t.Fatalf("event context = %+v", scan)
}
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
t.Fatalf("source context = %+v", scan)
}
}
@@ -1128,7 +1136,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
Event: autoscan.Event{
ID: 42,
SourceID: &sourceID,
InstallationID: 7,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: started,
CompletedAt: completed,
@@ -1258,18 +1266,30 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
runningStartedAt := time.Now().Add(-90 * time.Second)
sourceID := "src-1"
store := &fakeAutoscanStore{
getSettingsFn: func() (autoscan.Settings, error) {
return autoscan.Settings{Enabled: true}, nil
},
listSourcesFn: func() ([]autoscan.Source, error) {
return []autoscan.Source{
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
}, nil
},
queueSummaryFn: func() (autoscan.QueueSummary, error) {
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
},
listRunningFn: func() ([]autoscan.Event, error) {
return []autoscan.Event{{
ID: 44,
SourceID: &sourceID,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: runningStartedAt,
Status: autoscan.EventStatusRunning,
}}, nil
},
latestEventAtFn: func() (*time.Time, error) {
return &latestEventAt, nil
},
@@ -1296,6 +1316,16 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
t.Errorf("queue summary = %+v", body)
}
if len(body.RunningPolls) != 1 {
t.Fatalf("running_polls = %+v", body.RunningPolls)
}
poll := body.RunningPolls[0]
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
t.Fatalf("running poll identity = %+v", poll)
}
if poll.ElapsedMS <= 0 {
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
}
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
}
+3
View File
@@ -221,6 +221,9 @@ func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *h
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
if n > 100 {
n = 100
}
limit = n
}
+1 -1
View File
@@ -497,7 +497,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode":
case "movie", "episode", "audiobook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
+3 -1
View File
@@ -2036,8 +2036,10 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
levels = []string{"series", "season", "episode"}
case "movies", "movie":
levels = []string{"movie"}
case "audiobooks", "audiobook":
levels = []string{"audiobook"}
case "mixed":
levels = []string{"movie", "series", "season", "episode"}
levels = []string{"movie", "series", "season", "episode", "audiobook"}
default:
return nil
}
+139
View File
@@ -0,0 +1,139 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/playback"
)
var ErrServerRestartAlreadyRequested = errors.New("server restart already requested")
type ServerControlHandler struct {
requestRestart func(ctx context.Context) error
commands *playback.CommandDispatcher
}
type serverRestartRequest struct {
Reason string `json:"reason"`
Title string `json:"title"`
Message string `json:"message"`
}
type serverRestartResponse struct {
Status string `json:"status"`
Message string `json:"message"`
NotifiedSessions int `json:"notified_sessions"`
}
func NewServerControlHandler(
requestRestart func(ctx context.Context) error,
commands *playback.CommandDispatcher,
) *ServerControlHandler {
return &ServerControlHandler{
requestRestart: requestRestart,
commands: commands,
}
}
// HandleRestart handles POST /admin/server/restart.
func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Request) {
if h == nil || h.requestRestart == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Server restart is unavailable")
return
}
var req serverRestartRequest
if err := decodeOptionalJSONBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
notifiedSessions := h.notifyPlaybackSessions(req)
if err := h.requestRestart(context.Background()); err != nil {
if errors.Is(err, ErrServerRestartAlreadyRequested) {
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "already_requested",
Message: "Server restart is already in progress.",
NotifiedSessions: notifiedSessions,
})
return
}
slog.Error("server restart request failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
return
}
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "restart_requested",
Message: "Server restart requested. The process will shut down gracefully.",
NotifiedSessions: notifiedSessions,
})
}
func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest) int {
if h == nil || h.commands == nil {
return 0
}
title := req.Title
if title == "" {
title = "Server restarting"
}
message := req.Message
if message == "" {
message = "The server is restarting now. Playback may reconnect shortly."
}
payload, err := json.Marshal(map[string]string{
"title": title,
"message": message,
})
if err != nil {
slog.Warn("server restart: failed to build realtime payload", "error", err)
return 0
}
results := h.commands.DispatchToAll(func(session *playback.Session) (playback.CommandEnvelope, time.Duration, func(), error) {
if session == nil {
return playback.CommandEnvelope{}, 0, nil, playback.ErrCommandDispatchUnavailable
}
command, err := playback.NewCommandEnvelope(
session.ID,
uuid.NewString(),
playback.CommandServerRestarting,
payload,
)
if err != nil {
return playback.CommandEnvelope{}, 0, nil, err
}
command.Reason = req.Reason
if command.Reason == "" {
command.Reason = "server_restart_requested"
}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
return command, 0, nil, nil
})
delivered := 0
for _, result := range results {
if result.Delivered {
delivered++
continue
}
if result.DispatchErr != nil && !errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
slog.Warn("server restart: failed to notify playback session",
"session_id", result.SessionID,
"error", result.DispatchErr,
)
}
}
return delivered
}
@@ -0,0 +1,156 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/playback"
)
type serverControlTestConn struct {
messages []any
}
func (c *serverControlTestConn) WriteJSON(v any) error {
c.messages = append(c.messages, v)
return nil
}
func TestServerControlRestartRequestsShutdown(t *testing.T) {
t.Parallel()
called := 0
handler := NewServerControlHandler(func(context.Context) error {
called++
return nil
}, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if called != 1 {
t.Fatalf("restart calls = %d, want 1", called)
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "restart_requested" {
t.Fatalf("status = %q, want restart_requested", resp.Status)
}
if resp.NotifiedSessions != 0 {
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
}
}
func TestServerControlRestartUnavailable(t *testing.T) {
t.Parallel()
handler := NewServerControlHandler(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestServerControlRestartAlreadyRequested(t *testing.T) {
t.Parallel()
handler := NewServerControlHandler(func(context.Context) error {
return ErrServerRestartAlreadyRequested
}, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "already_requested" {
t.Fatalf("status = %q, want already_requested", resp.Status)
}
}
func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
t.Parallel()
sessionMgr := playback.NewSessionManager(0, 0)
session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
realtimeHub := playback.NewRealtimeHub()
conn := &serverControlTestConn{}
registration := realtimeHub.Register(session.ID, conn)
if registration == nil {
t.Fatal("expected realtime registration")
}
defer realtimeHub.Unregister(registration)
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
handler := NewServerControlHandler(func(context.Context) error {
return nil
}, dispatcher)
req := httptest.NewRequest(
http.MethodPost,
"/admin/server/restart",
strings.NewReader(`{"reason":"maintenance","message":"Restarting for maintenance"}`),
)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.NotifiedSessions != 1 {
t.Fatalf("notified_sessions = %d, want 1", resp.NotifiedSessions)
}
if len(conn.messages) != 1 {
t.Fatalf("messages = %d, want 1", len(conn.messages))
}
command, ok := conn.messages[0].(playback.CommandEnvelope)
if !ok {
t.Fatalf("message type = %T, want playback.CommandEnvelope", conn.messages[0])
}
if command.Name != playback.CommandServerRestarting {
t.Fatalf("command name = %q, want %q", command.Name, playback.CommandServerRestarting)
}
if command.Reason != "maintenance" {
t.Fatalf("reason = %q, want maintenance", command.Reason)
}
var payload map[string]string
if err := json.Unmarshal(command.Payload, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["message"] != "Restarting for maintenance" {
t.Fatalf("payload message = %q, want custom message", payload["message"])
}
}
+4 -2
View File
@@ -17,14 +17,16 @@ import (
type SystemHandler struct {
transcodePool *nodepool.TranscodePool
jwtSecret string
ffmpegPath string
buildInfo buildinfo.Info
}
// NewSystemHandler creates a SystemHandler.
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string) *SystemHandler {
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, ffmpegPath string) *SystemHandler {
return &SystemHandler{
transcodePool: transcodePool,
jwtSecret: jwtSecret,
ffmpegPath: ffmpegPath,
buildInfo: buildinfo.Current(),
}
}
@@ -45,7 +47,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
}
}
info := playback.DetectHWAccel()
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
writeJSON(w, http.StatusOK, info)
}
+5 -5
View File
@@ -32,11 +32,11 @@ type tasteSeedSubmitResponse struct {
// HandleTasteSeedItems handles GET /recommendations/taste-seed/items.
//
// Returns a paginated, hydrated list of "popular on this server" posters used
// for the new-user taste-seeding picker. Blends server-watched popularity with
// TMDB rating so fresh servers (no watch history yet) still surface meaningful
// content. The user_state field carries the existing is_favorite flag, so the
// UI can pre-select items the profile already favorited.
// Returns a paginated, hydrated list of posters used for the new-user
// taste-seeding picker. Blends server-watched popularity with rating reliability
// and recency so fresh servers (no watch history yet) still surface recognizable
// content. The user_state field carries the existing is_favorite flag, so the UI
// can pre-select items the profile already favorited.
func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) {
if h.recsRepo == nil || h.Fetcher == nil {
writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}})
+9 -18
View File
@@ -143,6 +143,7 @@ type Dependencies struct {
PlaybackRealtimeHub *playback.RealtimeHub
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
RequestServerRestart func(ctx context.Context) error
// UserCollectionSync handles per-profile imported collections (TMDB /
// Trakt / MDBList) — the user-facing analogue of CollectionService.
@@ -164,9 +165,6 @@ type Dependencies struct {
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
// AudiobooksEnabled gates native audiobook admin/UI routes behind the same
// server setting as the ABS compatibility listener.
AudiobooksEnabled bool
}
// absHandler is the narrow interface the router needs from the ABS handler.
@@ -579,6 +577,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Build playback handler if session manager is available.
var playbackHandler *handlers.PlaybackHandler
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
var playbackCommandDispatcher *playback.CommandDispatcher
var streamHandler *handlers.StreamHandler
var watchTogetherHandler *handlers.WatchTogetherHandler
if deps.SessionMgr != nil {
@@ -662,6 +661,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.RealtimeHub = realtimeHub
playbackHandler.CommandTracker = commandTracker
playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker)
playbackCommandDispatcher = playbackHandler.CommandDispatcher
playbackHandler.IntroAnalyzer = deps.IntroAnalyzer
playbackHandler.IntroRepository = deps.IntroRepository
playbackHandler.MarkerRegistry = deps.MarkerRegistry
@@ -700,6 +700,8 @@ func NewRouter(deps Dependencies) chi.Router {
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
}
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
// Build admin handler if we have a user repo.
var adminHandler *handlers.AdminHandler
var catalogSeedHandler *handlers.CatalogSeedHandler
@@ -1809,20 +1811,6 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/sections/recipes", recipeHandler.HandleList)
r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates)
// Audiobook endpoints (no profile required — catalog-level list/detail).
if deps.AudiobooksEnabled && itemRepo != nil {
audiobookHandler := &handlers.AudiobookHandler{
Items: itemRepo,
Files: deps.FileRepo,
Detail: detailSvc,
StoreProvider: deps.UserStoreProvider,
Recs: recsRepoForStale,
}
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)
r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)
}
// Section endpoints (profile-scoped).
if sectionHandler != nil {
r.Group(func(r chi.Router) {
@@ -1920,6 +1908,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
r.Post("/server/restart", serverControlHandler.HandleRestart)
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
if sectionSettingsHandler != nil {
@@ -2113,10 +2102,12 @@ func NewRouter(deps Dependencies) chi.Router {
// System inspection.
{
sysJWTSecret := ""
sysFFmpegPath := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
sysFFmpegPath = deps.Config.Playback.FFmpegPath
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
r.Route("/system", func(r chi.Router) {
r.Get("/build", systemHandler.HandleBuildInfo)
r.Get("/hw-accel", systemHandler.HandleHWAccel)
@@ -18,7 +18,8 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
slog.Warn("abs author access resolution failed", "err", err, "id", id)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, access)
@@ -47,7 +48,8 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
slog.Warn("abs series access resolution failed", "err", err, "id", id)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
series, err := h.deps.MediaStore.GetSeriesByName(r.Context(), id, access)
@@ -44,7 +44,9 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if got["name"] != "Brandon Sanderson" {
t.Errorf("name = %v", got["name"])
}
@@ -78,7 +80,9 @@ func TestSeries_Detail_ReturnsBooks(t *testing.T) {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if got["name"] != "Mistborn" {
t.Errorf("name = %v", got["name"])
}
+6 -1
View File
@@ -61,7 +61,12 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
if err != nil || item == nil {
if err != nil {
slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "item lookup failed", http.StatusInternalServerError)
return
}
if item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
@@ -157,10 +158,14 @@ func (p *recordingPublisher) snapshot() []publishedEvent {
// handler tests can drive both the 200 and 404 branches.
type stubMediaStore struct {
noopMediaStore
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
lookupErr error
}
func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string, _ catalog.AccessFilter) (*models.MediaItem, error) {
if s.lookupErr != nil {
return nil, s.lookupErr
}
if it, ok := s.known[id]; ok {
if it == nil {
return &models.MediaItem{ContentID: id}, nil
@@ -411,6 +416,19 @@ func TestMissingItem_404(t *testing.T) {
}
}
func TestItemLookup_ErrorFromMediaStore_500(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
hb.H.deps.MediaStore = &stubMediaStore{
known: map[string]*models.MediaItem{"book-1": nil},
lookupErr: errors.New("media lookup failed"),
}
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
}
}
func TestInvalidBody_400(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{not json`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
+14 -3
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
@@ -39,7 +40,12 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name == nil || *body.Name == "" {
if body.Name == nil {
http.Error(w, "name required", http.StatusBadRequest)
return
}
name := strings.TrimSpace(*body.Name)
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
@@ -48,7 +54,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request)
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
Name: *body.Name,
Name: name,
}
if body.Description != nil {
c.Description = *body.Description
@@ -267,7 +273,12 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
return
}
if body.Name != nil {
c.Name = *body.Name
name := strings.TrimSpace(*body.Name)
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
c.Name = name
}
if body.Description != nil {
c.Description = *body.Description
@@ -25,7 +25,7 @@ import (
// and profile_id; we mirror that so List can filter correctly).
type memCollectionStore struct {
mu sync.Mutex
rows map[string]Collection // id -> row
rows map[string]Collection // id -> row
items map[string][]CollectionItem // collection_id -> items
}
@@ -281,8 +281,16 @@ func TestCollection_List_DoesNotLeakOtherUsers(t *testing.T) {
// User 2 lists.
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "2", "", hb.H.handleListCollections)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, _ := env["collections"].([]any)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
list, ok := env["collections"].([]any)
if !ok {
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
}
if len(list) != 0 {
t.Errorf("user 2 sees %d collections, want 0", len(list))
}
@@ -295,8 +303,16 @@ func TestCollection_List_ProfileIsolation(t *testing.T) {
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"A"}`), "1", pA, hb.H.handleCreateCollection)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "1", pB, hb.H.handleListCollections)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, _ := env["collections"].([]any)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
list, ok := env["collections"].([]any)
if !ok {
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
}
if len(list) != 0 {
t.Errorf("profile B sees %d collections, want 0", len(list))
}
@@ -32,7 +32,12 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
if err != nil || item == nil {
if err != nil {
slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "item lookup failed", http.StatusInternalServerError)
return
}
if item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
@@ -3,6 +3,7 @@ package abs
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"testing"
@@ -73,3 +74,18 @@ func TestContinue_UnknownItem_404(t *testing.T) {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestContinue_MediaLookupError_500(t *testing.T) {
prog := &recordingProgressFake{}
media := &stubMediaStore{
known: map[string]*models.MediaItem{"book-1": nil},
lookupErr: errors.New("media lookup failed"),
}
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening",
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
}
}
+13 -9
View File
@@ -151,14 +151,18 @@ func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Reques
// returns the empty progress shape so the client can store offline state
// without raising an error.
func (h *Handler) handleSetEpisodeProgress(w http.ResponseWriter, r *http.Request) {
if a, ok := absAuthFrom(r); !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"libraryItemId": chi.URLParam(r, "libraryItemId"),
"episodeId": chi.URLParam(r, "episodeId"),
"currentTime": 0.0,
"duration": 0.0,
"isFinished": false,
"progress": 0.0,
"lastUpdate": 0,
"libraryItemId": chi.URLParam(r, "libraryItemId"),
"episodeId": chi.URLParam(r, "episodeId"),
"currentTime": 0.0,
"duration": 0.0,
"isFinished": false,
"progress": 0.0,
"lastUpdate": 0,
})
}
@@ -182,8 +186,8 @@ func (h *Handler) handleEbookFile(w http.ResponseWriter, _ *http.Request) {
// optimistic update succeeds.
func (h *Handler) handleEbookStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"libraryItemId": chi.URLParam(r, "id"),
"fileId": chi.URLParam(r, "fileid"),
"libraryItemId": chi.URLParam(r, "id"),
"fileId": chi.URLParam(r, "fileid"),
"isSupplementary": false,
})
}
+8 -11
View File
@@ -21,10 +21,8 @@ type SettingsReader interface {
GetString(ctx context.Context, key string) (string, error)
}
// Service is the audiobooks feature's top-level orchestrator. Sub-plan 1
// exposes only Enabled(); subsequent sub-plans hang additional methods
// off Service as new capabilities (scanner branches, ABS handlers, etc.)
// come online.
// Service owns the Audiobookshelf compatibility adapters. Native audiobook
// libraries are normal catalog media_items and do not depend on this service.
type Service struct {
settings SettingsReader
ABSHandler *abs.Handler
@@ -181,17 +179,16 @@ func buildABSRecommender(deps ABSHandlerDeps) abs.Recommender {
return &ABSRecommender{Pool: deps.Pool, Recs: deps.Recs}
}
// Enabled reports whether the audiobooks feature flag (set by
// 160_audiobooks_feature_flag and toggled by operators) is currently true.
// Any value other than the literal string "true" reads as false; this matches how silo
// treats other boolean server_settings rows.
func (s *Service) Enabled(ctx context.Context) (bool, error) {
// ABSCompatEnabled reports whether the Audiobookshelf compatibility listener
// should be started. Native audiobook libraries intentionally do not read this
// setting.
func (s *Service) ABSCompatEnabled(ctx context.Context) (bool, error) {
if s == nil || s.settings == nil {
return false, nil
}
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
value, err := s.settings.GetString(ctx, "audiobookshelf_compat.enabled")
if err != nil {
return false, fmt.Errorf("read audiobooks.enabled: %w", err)
return false, fmt.Errorf("read audiobookshelf_compat.enabled: %w", err)
}
return value == "true", nil
}
+16 -16
View File
@@ -12,13 +12,13 @@ type fakeSettingsReader struct {
}
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
if key != "audiobooks.enabled" {
if key != "audiobookshelf_compat.enabled" {
return "", errors.New("unexpected key: " + key)
}
return f.value, f.err
}
func TestServiceEnabledReadsFlag(t *testing.T) {
func TestServiceABSCompatEnabledReadsFlag(t *testing.T) {
cases := []struct {
name string
stored string
@@ -33,44 +33,44 @@ func TestServiceEnabledReadsFlag(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
svc := New(&fakeSettingsReader{value: tc.stored})
got, err := svc.Enabled(context.Background())
got, err := svc.ABSCompatEnabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
t.Fatalf("ABSCompatEnabled returned error: %v", err)
}
if got != tc.want {
t.Fatalf("Enabled = %v, want %v", got, tc.want)
t.Fatalf("ABSCompatEnabled = %v, want %v", got, tc.want)
}
})
}
}
func TestServiceEnabledPropagatesError(t *testing.T) {
func TestServiceABSCompatEnabledPropagatesError(t *testing.T) {
wantErr := errors.New("db down")
svc := New(&fakeSettingsReader{err: wantErr})
_, err := svc.Enabled(context.Background())
_, err := svc.ABSCompatEnabled(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
t.Fatalf("ABSCompatEnabled error = %v, want %v wrapped", err, wantErr)
}
}
func TestServiceEnabledNilReceiverReturnsFalse(t *testing.T) {
func TestServiceABSCompatEnabledNilReceiverReturnsFalse(t *testing.T) {
var svc *Service
got, err := svc.Enabled(context.Background())
got, err := svc.ABSCompatEnabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
t.Fatalf("ABSCompatEnabled returned error: %v", err)
}
if got {
t.Fatal("Enabled = true, want false")
t.Fatal("ABSCompatEnabled = true, want false")
}
}
func TestServiceEnabledNilSettingsReturnsFalse(t *testing.T) {
func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
svc := New(nil)
got, err := svc.Enabled(context.Background())
got, err := svc.ABSCompatEnabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
t.Fatalf("ABSCompatEnabled returned error: %v", err)
}
if got {
t.Fatal("Enabled = true, want false")
t.Fatal("ABSCompatEnabled = true, want false")
}
}
+9 -39
View File
@@ -9,19 +9,17 @@ import (
// enriched with the metadata the Add-source picker needs (plugin id + a
// human-friendly display name).
type DiscoveredSource struct {
InstallationID int
CapabilityID string
// PluginID is the installation's plugin id (e.g. "sonarr"); empty when the
// lister cannot supply it.
PluginID string
PluginID string
CapabilityID string
// DisplayName is a human-friendly label for the capability (from the
// capability's manifest display_name, falling back to plugin/capability ids).
DisplayName string
}
// ScanSourceLister enumerates every installed scan_source.v1 capability so the
// engine can (a) offer them in the Add-source picker and (b) detect orphaned
// source rows whose plugin has been uninstalled.
// engine can offer them in the Add-source picker.
type ScanSourceLister interface {
// ListScanSources returns one entry per installed scan_source.v1 capability,
// enriched with plugin id + display name.
@@ -31,17 +29,9 @@ type ScanSourceLister interface {
// AvailableScanSource is one installed scan_source capability an operator can
// create a source against (the Add-source picker list).
type AvailableScanSource struct {
InstallationID int `json:"installation_id"`
CapabilityID string `json:"capability_id"`
PluginID string `json:"plugin_id"`
DisplayName string `json:"display_name"`
}
// installedKey identifies an installed scan_source capability for set
// membership tests (orphan detection in PollOnce).
type installedKey struct {
InstallationID int
CapabilityID string
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
}
// ListAvailableScanSources enumerates every installed scan_source capability so
@@ -59,30 +49,10 @@ func (s *Service) ListAvailableScanSources(ctx context.Context) ([]AvailableScan
out := make([]AvailableScanSource, 0, len(discovered))
for _, d := range discovered {
out = append(out, AvailableScanSource{
InstallationID: d.InstallationID,
CapabilityID: d.CapabilityID,
PluginID: d.PluginID,
DisplayName: d.DisplayName,
PluginID: d.PluginID,
CapabilityID: d.CapabilityID,
DisplayName: d.DisplayName,
})
}
return out, nil
}
// installedScanSources returns the set of currently-installed scan_source
// capabilities so PollOnce can skip orphaned source rows (their plugin is
// gone). A nil set means the set is unavailable (no lister) — callers must treat
// that as "discovery unavailable" and NOT prune.
func (s *Service) installedScanSources(ctx context.Context) (map[installedKey]struct{}, error) {
if s.lister == nil {
return nil, nil
}
discovered, err := s.lister.ListScanSources(ctx)
if err != nil {
return nil, fmt.Errorf("list scan sources: %w", err)
}
present := make(map[installedKey]struct{}, len(discovered))
for _, d := range discovered {
present[installedKey{InstallationID: d.InstallationID, CapabilityID: d.CapabilityID}] = struct{}{}
}
return present, nil
}
+3 -32
View File
@@ -14,8 +14,8 @@ func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error)
func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
lister := fakeLister{sources: []DiscoveredSource{
{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"},
{InstallationID: 2, CapabilityID: "arr-b", PluginID: "radarr", DisplayName: "Radarr"},
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
{PluginID: "radarr", CapabilityID: "arr-b", DisplayName: "Radarr"},
}}
svc := &Service{lister: lister}
@@ -26,7 +26,7 @@ func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
if len(available) != 2 {
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
}
if available[0] != (AvailableScanSource{InstallationID: 1, CapabilityID: "arr-a", PluginID: "sonarr", DisplayName: "Sonarr"}) {
if available[0] != (AvailableScanSource{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"}) {
t.Fatalf("unexpected first available: %+v", available[0])
}
}
@@ -41,32 +41,3 @@ func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) {
t.Fatalf("nil lister must return empty, got %+v", available)
}
}
func TestInstalledScanSourcesSetMembership(t *testing.T) {
lister := fakeLister{sources: []DiscoveredSource{
{InstallationID: 1, CapabilityID: "arr-a"},
}}
svc := &Service{lister: lister}
present, err := svc.installedScanSources(context.Background())
if err != nil {
t.Fatalf("installedScanSources: %v", err)
}
if _, ok := present[installedKey{1, "arr-a"}]; !ok {
t.Fatalf("expected 1/arr-a present: %+v", present)
}
if _, ok := present[installedKey{2, "arr-b"}]; ok {
t.Fatalf("did not expect 2/arr-b present: %+v", present)
}
}
func TestInstalledScanSourcesNilListerNilSet(t *testing.T) {
svc := &Service{lister: nil}
present, err := svc.installedScanSources(context.Background())
if err != nil {
t.Fatalf("installedScanSources: %v", err)
}
if present != nil {
t.Fatalf("nil lister must return a nil set, got %+v", present)
}
}
+4
View File
@@ -5,3 +5,7 @@ import "errors"
// ErrNotFound is returned when an autoscan operation references a connection or
// source that does not exist. The admin API surfaces it as a 404.
var ErrNotFound = errors.New("autoscan: not found")
// ErrPollAlreadyRunning is returned when a source already has an active poll
// event. Callers should treat it as a no-op skip, not a source failure.
var ErrPollAlreadyRunning = errors.New("autoscan: source poll already running")
+2 -2
View File
@@ -69,7 +69,7 @@ func TestTestConnectionNoProbeConfigured(t *testing.T) {
func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
store := &fakeStore{
sources: []Source{
{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1")},
{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1")},
},
}
arr := &fakeArrClient{roots: []string{"/data/tv"}}
@@ -87,7 +87,7 @@ func TestSuggestRewritesMatchesArrRootsToSiloFolders(t *testing.T) {
func TestSuggestRewritesNoConnection(t *testing.T) {
store := &fakeStore{
sources: []Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}},
sources: []Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}},
}
svc := &Service{store: store, connres: passthroughConnRes{}, rootFolders: &fakeArrClient{}, folders: fakeFolderLister{}}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
// ScanSourceProvider yields changed paths for one source. The engine calls
// PollChanges; production wraps the plugins.Service scan_source resolver.
type ScanSourceProvider interface {
PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) (changes []Change, nextMarker string, err error)
}
// PollChangesClient is the slice of *pluginhost.ScanSourceClient used here. It
@@ -20,10 +20,10 @@ type PollChangesClient interface {
PollChanges(ctx context.Context, req *pluginv1.PollChangesRequest) (*pluginv1.PollChangesResponse, error)
}
// ScanSourceResolver yields a per-(installation, capability) scan-source client.
// ScanSourceResolver yields a per-(plugin, capability) scan-source client.
// Exported for the same cross-package adapter reason as PollChangesClient.
type ScanSourceResolver interface {
ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (PollChangesClient, error)
ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (PollChangesClient, error)
}
type pluginProvider struct{ resolver ScanSourceResolver }
@@ -34,8 +34,8 @@ func NewPluginProvider(resolver ScanSourceResolver) ScanSourceProvider {
return &pluginProvider{resolver: resolver}
}
func (p *pluginProvider) PollChanges(ctx context.Context, installationID int, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
client, err := p.resolver.ScanSourceClient(ctx, installationID, capabilityID)
func (p *pluginProvider) PollChanges(ctx context.Context, pluginID, capabilityID, marker string, conn ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
client, err := p.resolver.ScanSourceClient(ctx, pluginID, capabilityID)
if err != nil {
return nil, "", err
}
+18 -7
View File
@@ -17,10 +17,17 @@ func (c *capturingClient) PollChanges(_ context.Context, req *pluginv1.PollChang
return &pluginv1.PollChangesResponse{SourcePaths: []string{"/mnt/media/x"}, NextMarker: "m2"}, nil
}
// capturingResolver yields a fixed PollChangesClient.
type capturingResolver struct{ client PollChangesClient }
// capturingResolver yields a fixed PollChangesClient and records the requested
// stable scan-source identity.
type capturingResolver struct {
client PollChangesClient
lastPluginID string
lastCapID string
}
func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollChangesClient, error) {
func (r *capturingResolver) ScanSourceClient(_ context.Context, pluginID, capabilityID string) (PollChangesClient, error) {
r.lastPluginID = pluginID
r.lastCapID = capabilityID
return r.client, nil
}
@@ -28,11 +35,12 @@ func (r capturingResolver) ScanSourceClient(context.Context, int, string) (PollC
// delivered to the plugin on the PollChangesRequest.
func TestPluginProviderPopulatesConnection(t *testing.T) {
client := &capturingClient{}
prov := NewPluginProvider(capturingResolver{client})
resolver := &capturingResolver{client: client}
prov := NewPluginProvider(resolver)
conn := ResolvedConnection{BaseURL: "https://arr.example", APIKey: "secret-key"}
sourceConfig := map[string]string{"exclusions": ".downloads"}
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", conn, sourceConfig)
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", conn, sourceConfig)
if err != nil {
t.Fatalf("PollChanges: %v", err)
}
@@ -45,6 +53,9 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
if client.last.GetCapabilityId() != "cap" || client.last.GetMarker() != "m1" {
t.Fatalf("unexpected request fields: cap=%q marker=%q", client.last.GetCapabilityId(), client.last.GetMarker())
}
if resolver.lastPluginID != "silo.autoscan.arr" || resolver.lastCapID != "cap" {
t.Fatalf("resolver identity = %q/%q", resolver.lastPluginID, resolver.lastCapID)
}
rc := client.last.GetConnection()
if rc == nil {
t.Fatal("expected connection to be populated on the request")
@@ -59,9 +70,9 @@ func TestPluginProviderPopulatesConnection(t *testing.T) {
func TestPluginProviderPrefersStructuredChanges(t *testing.T) {
client := &capturingStructuredClient{}
prov := NewPluginProvider(capturingResolver{client: client})
prov := NewPluginProvider(&capturingResolver{client: client})
changes, next, err := prov.PollChanges(context.Background(), 1, "cap", "m1", ResolvedConnection{}, nil)
changes, next, err := prov.PollChanges(context.Background(), "silo.autoscan.arr", "cap", "m1", ResolvedConnection{}, nil)
if err != nil {
t.Fatalf("PollChanges: %v", err)
}
+124 -26
View File
@@ -175,14 +175,14 @@ func (r *Repository) GetConnection(ctx context.Context, id string) (Connection,
// --- Sources ---
const sourceColumns = `id, installation_id, capability_id, connection_id, enabled,
const sourceColumns = `id, plugin_id, capability_id, connection_id, enabled,
poll_interval_seconds, path_rewrites, source_config, label, marker, last_run_at, last_error`
func scanSource(row interface{ Scan(...any) error }) (Source, error) {
var s Source
var pathRewrites []byte
var sourceConfig []byte
if err := row.Scan(&s.ID, &s.InstallationID, &s.CapabilityID, &s.ConnectionID,
if err := row.Scan(&s.ID, &s.PluginID, &s.CapabilityID, &s.ConnectionID,
&s.Enabled, &s.PollIntervalSeconds, &pathRewrites, &sourceConfig, &s.Label, &s.Marker, &s.LastRunAt, &s.LastError); err != nil {
return Source{}, err
}
@@ -283,11 +283,11 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO autoscan_sources (
installation_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
plugin_id, capability_id, connection_id, enabled, poll_interval_seconds, path_rewrites, source_config, label
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+sourceColumns,
s.InstallationID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
s.PluginID, s.CapabilityID, connectionIDArg(s.ConnectionID), s.Enabled, s.PollIntervalSeconds, rewrites, sourceConfig, s.Label)
out, err := scanSource(row)
if err != nil {
if connID, ok := connectionFKViolation(err, s.ConnectionID); ok {
@@ -299,7 +299,7 @@ func (r *Repository) CreateSource(ctx context.Context, s Source) (Source, error)
}
// UpdateSource updates a source's binding/scheduling fields by id. Identity
// (installation_id, capability_id) and bookkeeping fields (marker/last_run_at/
// (plugin_id, capability_id) and bookkeeping fields (marker/last_run_at/
// last_error) are left untouched. An unknown id maps to ErrNotFound; a
// non-existent connection trips the FK constraint and also maps to ErrNotFound.
func (r *Repository) UpdateSource(ctx context.Context, s Source) (Source, error) {
@@ -353,7 +353,7 @@ func connectionFKViolation(err error, connectionID *string) (string, bool) {
func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
FROM autoscan_sources ORDER BY installation_id, capability_id`)
FROM autoscan_sources ORDER BY plugin_id, capability_id`)
if err != nil {
return nil, fmt.Errorf("list autoscan sources: %w", err)
}
@@ -364,7 +364,7 @@ func (r *Repository) ListSources(ctx context.Context) ([]Source, error) {
func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) {
rows, err := r.pool.Query(ctx, `SELECT `+sourceColumns+`
FROM autoscan_sources WHERE enabled = true
ORDER BY installation_id, capability_id`)
ORDER BY plugin_id, capability_id`)
if err != nil {
return nil, fmt.Errorf("list enabled autoscan sources: %w", err)
}
@@ -462,7 +462,7 @@ func (r *Repository) RecordError(ctx context.Context, sourceID, msg string) erro
return nil
}
const eventColumns = `id, source_id, installation_id, capability_id, started_at, completed_at,
const eventColumns = `id, source_id, plugin_id, capability_id, started_at, completed_at,
duration_ms, status, changes_returned, changes_resolved, targets_claimed, scans_created,
scans_reused, scans_suppressed, error_message, marker_before, marker_after`
@@ -472,7 +472,7 @@ func scanEvent(row interface{ Scan(...any) error }) (Event, error) {
if err := row.Scan(
&e.ID,
&e.SourceID,
&e.InstallationID,
&e.PluginID,
&e.CapabilityID,
&e.StartedAt,
&e.CompletedAt,
@@ -499,25 +499,80 @@ func (r *Repository) CreateEvent(ctx context.Context, in EventCreate) (int64, er
if started.IsZero() {
started = time.Now()
}
row := r.pool.QueryRow(ctx, `
sourceID := strings.TrimSpace(in.SourceID)
if sourceID == "" {
row := r.pool.QueryRow(ctx, `
INSERT INTO autoscan_events (
source_id, plugin_id, capability_id, started_at, completed_at,
duration_ms, status, error_message, marker_before
)
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
RETURNING id`,
nil,
in.PluginID,
in.CapabilityID,
started,
string(EventStatusRunning),
"",
nullable(in.MarkerBefore),
)
var id int64
if err := row.Scan(&id); err != nil {
return 0, fmt.Errorf("create autoscan event: %w", err)
}
return id, nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin autoscan event: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(900173, hashtext($1))`, sourceID); err != nil {
return 0, fmt.Errorf("lock autoscan event: %w", err)
}
var runningID int64
err = tx.QueryRow(ctx, `
SELECT id
FROM autoscan_events
WHERE source_id = $1
AND status = $2
ORDER BY started_at ASC, id ASC
LIMIT 1`,
sourceID,
string(EventStatusRunning),
).Scan(&runningID)
if err == nil {
return 0, fmt.Errorf("%w: source %s event %d", ErrPollAlreadyRunning, sourceID, runningID)
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("check running autoscan event: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO autoscan_events (
source_id, installation_id, capability_id, started_at, completed_at,
source_id, plugin_id, capability_id, started_at, completed_at,
duration_ms, status, error_message, marker_before
)
VALUES ($1, $2, $3, $4, $4, 0, $5, $6, $7)
RETURNING id`,
nullable(in.SourceID),
in.InstallationID,
sourceID,
in.PluginID,
in.CapabilityID,
started,
string(EventStatusError),
"poll started but did not finish",
string(EventStatusRunning),
"",
nullable(in.MarkerBefore),
)
var id int64
if err := row.Scan(&id); err != nil {
return 0, fmt.Errorf("create autoscan event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit autoscan event: %w", err)
}
return id, nil
}
@@ -565,6 +620,25 @@ func (r *Repository) FinishEvent(ctx context.Context, in EventFinish) error {
return nil
}
func (r *Repository) MarkInterruptedEvents(ctx context.Context) error {
msg := "poll started but did not finish"
_, err := r.pool.Exec(ctx, `
UPDATE autoscan_events
SET completed_at = now(),
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint,
status = $1,
error_message = $2
WHERE status = $3`,
string(EventStatusError),
msg,
string(EventStatusRunning),
)
if err != nil {
return fmt.Errorf("mark interrupted autoscan events: %w", err)
}
return nil
}
// clampAutoscanLimit bounds a requested page size to a sane window: the
// default page when unset, capped so a single query can never fan out.
func clampAutoscanLimit(limit int) int {
@@ -758,6 +832,30 @@ func (r *Repository) ListEvents(ctx context.Context, filter EventListFilter) ([]
return events, runRows.Err()
}
func (r *Repository) ListRunningEvents(ctx context.Context) ([]Event, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+eventColumns+`
FROM autoscan_events
WHERE status = $1
ORDER BY started_at ASC, id ASC`,
string(EventStatusRunning),
)
if err != nil {
return nil, fmt.Errorf("list running autoscan events: %w", err)
}
defer rows.Close()
events := make([]Event, 0)
for rows.Next() {
event, scanErr := scanEvent(rows)
if scanErr != nil {
return nil, scanErr
}
events = append(events, event)
}
return events, rows.Err()
}
func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilter) ([]ScanWithEvent, error) {
limit := clampAutoscanLimit(filter.Limit)
offset := filter.Offset
@@ -780,15 +878,15 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
sr.trigger,
sr.status,
COALESCE(sr.error_message, ''),
sr.requested_at,
sr.started_at,
sr.completed_at,
sr.autoscan_event_id,
e.source_id,
e.installation_id,
COALESCE(e.capability_id, ''),
COALESCE(e.status, ''),
e.completed_at
sr.requested_at,
sr.started_at,
sr.completed_at,
sr.autoscan_event_id,
e.source_id,
COALESCE(e.plugin_id, ''),
COALESCE(e.capability_id, ''),
COALESCE(e.status, ''),
e.completed_at
FROM scan_runs sr
LEFT JOIN autoscan_events e ON e.id = sr.autoscan_event_id
WHERE `+strings.Join(clauses, " AND ")+`
@@ -818,7 +916,7 @@ func (r *Repository) ListAutoscanScans(ctx context.Context, filter ScanListFilte
&scan.CompletedAt,
&scan.AutoscanEventID,
&scan.SourceID,
&scan.InstallationID,
&scan.PluginID,
&scan.CapabilityID,
&eventStatus,
&scan.EventCompletedAt,
@@ -853,7 +951,7 @@ func (r *Repository) GetQueueSummary(ctx context.Context) (QueueSummary, error)
func (r *Repository) LatestEventAt(ctx context.Context) (*time.Time, error) {
var latest *time.Time
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events`).Scan(&latest)
err := r.pool.QueryRow(ctx, `SELECT max(completed_at) FROM autoscan_events WHERE status <> $1`, string(EventStatusRunning)).Scan(&latest)
if err != nil {
return nil, fmt.Errorf("get latest autoscan event time: %w", err)
}
+22 -35
View File
@@ -91,8 +91,7 @@ func (s *Service) SetSuggesterDeps(rootFolders RootFolderClient, folders FolderL
// from scan_source plugins; connres resolves a source's connection to concrete
// credentials; resolver/queue/suppress drive the resolve→suppress→enqueue loop.
// lister enumerates installed scan_source capabilities so the Add-source picker
// can offer them and PollOnce can skip orphaned source rows; it may be nil
// (the picker then returns an empty list and orphan-pruning is disabled).
// can offer them; it may be nil (the picker then returns an empty list).
func NewService(
store Store,
provider ScanSourceProvider,
@@ -113,24 +112,15 @@ func NewService(
}
}
// PollOnce runs one autoscan cycle. Per-source failures are logged and skipped;
// only settings/listing errors propagate. The opaque next marker returned by the
// PollOnce runs one autoscan cycle. Per-source failures are logged, recorded on
// the source/event, and the loop continues; only settings/listing errors
// propagate. The opaque next marker returned by the
// provider is stored verbatim, but only when the cycle's work is genuinely
// consumed: the provider returned no paths, or it returned paths and at least one
// resolved+enqueued. When paths come back but NONE resolve to a library folder
// (e.g. a freshly-enabled source with unconfigured rewrites) the marker is held
// and an error recorded, so those imports aren't skipped forever.
func (s *Service) PollOnce(ctx context.Context) error {
// Fetch the set of currently-installed scan_source capabilities so we can skip
// orphaned source rows (their plugin was uninstalled). Listing failures are
// non-fatal: a nil set means the installed set is unavailable, in which case
// we do NOT prune orphans — every enabled source is assumed present.
present, derr := s.installedScanSources(ctx)
if derr != nil {
slog.WarnContext(ctx, "autoscan: list installed scan sources failed", "err", derr)
present = nil
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
return err
@@ -146,16 +136,6 @@ func (s *Service) PollOnce(ctx context.Context) error {
now := time.Now()
for _, src := range sources {
// Skip orphaned sources: an enabled source whose scan_source plugin has
// been uninstalled/disabled is no longer in the discovered set, so polling
// it would error every cycle. Skip it quietly (no RecordError) so the spam
// stops; the operator can delete it via the source delete endpoint. A nil
// `present` set means discovery is unavailable — don't prune in that case.
if present != nil {
if _, ok := present[installedKey{InstallationID: src.InstallationID, CapabilityID: src.CapabilityID}]; !ok {
continue
}
}
// Honor the per-source poll interval as a "poll at most every N seconds"
// floor: the global task fires at the default cadence, so a source with a
// longer interval is skipped until enough time has elapsed.
@@ -170,7 +150,10 @@ func (s *Service) PollOnce(ctx context.Context) error {
if src.Marker != nil {
marker = *src.Marker
}
eventID := s.createEvent(ctx, src, marker, time.Now())
eventID, started := s.createEvent(ctx, src, marker, time.Now())
if !started {
continue
}
// A connection is OPTIONAL. Server-based providers (Sonarr/Radarr) bind a
// connection and the resolved {base_url, api_key} is handed to the plugin.
// Other providers (e.g. a filesystem/CephFS watcher) need none and get an
@@ -195,7 +178,7 @@ func (s *Service) PollOnce(ctx context.Context) error {
}
conn = resolved
}
changes, next, perr := s.provider.PollChanges(ctx, src.InstallationID, src.CapabilityID, marker, conn, src.SourceConfig)
changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, src.CapabilityID, marker, conn, src.SourceConfig)
if perr != nil {
slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr)
if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil {
@@ -295,22 +278,26 @@ func (s *Service) PollOnce(ctx context.Context) error {
return nil
}
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) int64 {
func (s *Service) createEvent(ctx context.Context, src Source, marker string, startedAt time.Time) (int64, bool) {
if s == nil || s.store == nil {
return 0
return 0, true
}
id, err := s.store.CreateEvent(ctx, EventCreate{
SourceID: src.ID,
InstallationID: src.InstallationID,
CapabilityID: src.CapabilityID,
StartedAt: startedAt,
MarkerBefore: marker,
SourceID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
StartedAt: startedAt,
MarkerBefore: marker,
})
if err != nil {
if errors.Is(err, ErrPollAlreadyRunning) {
slog.DebugContext(ctx, "autoscan: source poll already running", "source_id", src.ID)
return 0, false
}
slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err)
return 0
return 0, true
}
return id
return id, true
}
func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFinish) {
+85 -38
View File
@@ -2,6 +2,7 @@ package autoscan
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
@@ -12,13 +13,14 @@ import (
)
type fakeStore struct {
settings Settings
sources []Source
connection Connection
advanced map[string]string // source ID -> marker
recorded map[string]string // source ID -> error message
createdEvents []EventCreate
events []EventFinish
settings Settings
sources []Source
connection Connection
advanced map[string]string // source ID -> marker
recorded map[string]string // source ID -> error message
createdEvents []EventCreate
events []EventFinish
createEventErr error
}
func (f *fakeStore) GetSettings(context.Context) (Settings, error) { return f.settings, nil }
@@ -51,6 +53,9 @@ func (f *fakeStore) RecordError(_ context.Context, sourceID, msg string) error {
return nil
}
func (f *fakeStore) CreateEvent(_ context.Context, event EventCreate) (int64, error) {
if f.createEventErr != nil {
return 0, f.createEventErr
}
f.createdEvents = append(f.createdEvents, event)
return int64(len(f.createdEvents)), nil
}
@@ -65,14 +70,20 @@ type fakeProvider struct {
changes map[string][]Change // key: capabilityID, structured changes
nextMarker string
err error
errByCap map[string]error
lastConfig map[string]string
calls int
}
func (f *fakeProvider) PollChanges(_ context.Context, _ int, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
func (f *fakeProvider) PollChanges(_ context.Context, _ string, capabilityID, _ string, _ ResolvedConnection, sourceConfig map[string]string) ([]Change, string, error) {
f.calls++
f.lastConfig = sourceConfig
if f.err != nil {
return nil, "", f.err
}
if err := f.errByCap[capabilityID]; err != nil {
return nil, "", err
}
if changes, ok := f.changes[capabilityID]; ok {
return changes, f.nextMarker, nil
}
@@ -87,7 +98,7 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
SourceConfig: map[string]string{"exclusions": ".downloads\n.recyclebin"},
}},
}
@@ -101,6 +112,38 @@ func TestPollOncePassesSourceConfigToProvider(t *testing.T) {
}
}
func TestPollOnceSkipsSourceWhenPollAlreadyRunning(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
createEventErr: ErrPollAlreadyRunning,
sources: []Source{{
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{"cephfs": {"/mnt/media/Movie/movie.mkv"}}, nextMarker: "m1"}
q := &recordingQueuer{}
svc := newService(store, prov, q, allowSuppressor{})
if err := svc.PollOnce(context.Background()); err != nil {
t.Fatalf("PollOnce: %v", err)
}
if prov.calls != 0 {
t.Fatalf("provider calls = %d, want 0", prov.calls)
}
if len(q.enqueued) != 0 {
t.Fatalf("enqueued scans = %+v, want none", q.enqueued)
}
if len(store.events) != 0 {
t.Fatalf("finished events = %+v, want none", store.events)
}
if len(store.recorded) != 0 {
t.Fatalf("recorded source errors = %+v, want none", store.recorded)
}
if len(store.advanced) != 0 {
t.Fatalf("advanced markers = %+v, want none", store.advanced)
}
}
// passthroughConnRes resolves to empty credentials; the engine doesn't inspect
// them in tests (the fake provider ignores conn).
type passthroughConnRes struct{}
@@ -210,7 +253,7 @@ func TestPollOnceEnqueuesDedupedFolders(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{
@@ -241,7 +284,7 @@ func TestPollOnceRecordsSuccessfulEvent(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true, Marker: &marker,
}},
}
prov := &fakeProvider{paths: map[string][]string{
@@ -283,7 +326,7 @@ func TestPollOnceRecordsReusedScanCounts(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{
@@ -318,7 +361,7 @@ func TestPollOnceAppliesSourceRewritesBeforeEnqueue(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
}},
}
@@ -347,7 +390,7 @@ func TestPollOnceStructuredFileChangeEnqueuesExactFile(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
PathRewrites: []PathRewrite{{From: "/ceph/tv", To: "/mnt/media/tv"}},
}},
}
@@ -380,7 +423,7 @@ func TestPollOnceStructuredSubtreeChangeEnqueuesExactSubtree(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "cephfs", Enabled: true,
ID: "s1", PluginID: "silo.autoscan.cephfs", CapabilityID: "cephfs", Enabled: true,
PathRewrites: []PathRewrite{{From: "/ceph/movies", To: "/mnt/media/movies"}},
}},
}
@@ -416,7 +459,7 @@ func TestPollOnceScansDistinctPathsUnderSameFolder(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{
@@ -446,7 +489,7 @@ func TestPollOnceStoresOpaqueMarkerVerbatim(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
const opaque = "eyJjdXJzb3IiOiJhYmMxMjMifQ==|2026-06-02T14:10:00Z"
@@ -470,7 +513,7 @@ func TestPollOnceHoldsMarkerWhenPathsReturnedButNoneResolve(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{
@@ -523,7 +566,7 @@ func TestPollOnceAdvancesMarkerWhenResolvedButAllSuppressed(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
// rewrite /data/tv -> /mnt/media/tv so fakeResolver resolves the paths.
PathRewrites: []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}},
}},
@@ -556,7 +599,7 @@ func TestPollOnceAdvancesMarkerWhenZeroPathsReturned(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{"arr": {}}, nextMarker: "m1"}
@@ -588,7 +631,7 @@ func TestPollOnceDisabledNoop(t *testing.T) {
func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
}
prov := &fakeProvider{err: context.DeadlineExceeded}
q := &recordingQueuer{}
@@ -607,7 +650,7 @@ func TestPollOnceProviderErrorKeepsMarker(t *testing.T) {
func TestPollOnceReleasesClaimOnEnqueueFailure(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
sources: []Source{{ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true}},
}
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
sup := &recordingSuppressor{}
@@ -640,7 +683,7 @@ func TestPollOncePollsConnectionlessSource(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: nil, Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil, Enabled: true,
}},
}
prov := &fakeProvider{paths: map[string][]string{"arr": {"/mnt/media/Show/S01/E01.mkv"}}, nextMarker: "m1"}
@@ -666,7 +709,7 @@ func TestPollOnceSkipsSourcePolledTooRecently(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
PollIntervalSeconds: &interval, LastRunAt: &recent,
}},
}
@@ -690,7 +733,7 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{{
ID: "s1", InstallationID: 1, CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
ID: "s1", PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: strptr("c1"), Enabled: true,
PollIntervalSeconds: &interval, LastRunAt: &old,
}},
}
@@ -708,25 +751,23 @@ func TestPollOnceRunsSourcePastItsInterval(t *testing.T) {
}
}
func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) {
// Two enabled sources: only "arr-live" is still discovered; "arr-gone"'s
// plugin has been uninstalled. The orphan must be skipped quietly — no poll,
// no RecordError spam — while the live source still polls normally.
func TestPollOnceRecordsScanSourceResolutionFailure(t *testing.T) {
// Two enabled sources: one polls normally, while the other simulates the
// production resolver failing because the plugin cannot be resolved.
store := &fakeStore{
settings: Settings{Enabled: true, DefaultPollIntervalSeconds: 600, DebounceSeconds: 60},
sources: []Source{
{ID: "live", InstallationID: 1, CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
{ID: "gone", InstallationID: 2, CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
{ID: "live", PluginID: "silo.autoscan.live", CapabilityID: "arr-live", ConnectionID: strptr("c1"), Enabled: true},
{ID: "gone", PluginID: "silo.autoscan.gone", CapabilityID: "arr-gone", ConnectionID: strptr("c1"), Enabled: true},
},
}
prov := &fakeProvider{paths: map[string][]string{
"arr-live": {"/mnt/media/Show/S01/E01.mkv"},
"arr-gone": {"/mnt/media/Other/S01/E01.mkv"},
}, errByCap: map[string]error{
"arr-gone": errors.New("scan source plugin \"silo.autoscan.gone\" is not installed"),
}, nextMarker: "m1"}
q := &recordingQueuer{}
// Lister reports only the live capability; the orphan is absent.
lister := fakeLister{sources: []DiscoveredSource{{InstallationID: 1, CapabilityID: "arr-live"}}}
svc := NewService(store, prov, passthroughConnRes{}, fakeResolver{}, q, allowSuppressor{}, lister)
svc := newService(store, prov, q, allowSuppressor{})
if err := svc.PollOnce(context.Background()); err != nil {
t.Fatalf("PollOnce: %v", err)
@@ -738,9 +779,15 @@ func TestPollOnceSkipsOrphanedSourceQuietly(t *testing.T) {
t.Fatalf("expected live source marker advanced")
}
if _, ok := store.advanced["gone"]; ok {
t.Fatalf("orphaned source must NOT poll/advance")
t.Fatalf("failed source must NOT advance")
}
if _, ok := store.recorded["gone"]; ok {
t.Fatalf("orphaned source must be skipped quietly, but an error was recorded: %q", store.recorded["gone"])
if got := store.recorded["gone"]; !strings.Contains(got, "not installed") {
t.Fatalf("failed source error = %q", got)
}
if len(store.events) != 2 {
t.Fatalf("expected one event per source, got %+v", store.events)
}
if store.events[1].Status != EventStatusError || !strings.Contains(store.events[1].ErrorMessage, "not installed") {
t.Fatalf("failed source event = %+v", store.events[1])
}
}
+10 -9
View File
@@ -45,11 +45,11 @@ type Change struct {
Scope ChangeScope
}
// Source ties a scan_source plugin capability instance to a connection plus the
// Source ties a scan_source plugin capability to a connection plus the
// host-owned scheduling/bookkeeping state.
type Source struct {
ID string
InstallationID int
PluginID string
CapabilityID string
ConnectionID *string // nil until an operator binds a connection
Enabled bool
@@ -65,6 +65,7 @@ type Source struct {
type EventStatus string
const (
EventStatusRunning EventStatus = "running"
EventStatusSuccess EventStatus = "success"
EventStatusError EventStatus = "error"
EventStatusUnresolved EventStatus = "unresolved"
@@ -73,7 +74,7 @@ const (
type Event struct {
ID int64
SourceID *string
InstallationID int
PluginID string
CapabilityID string
StartedAt time.Time
CompletedAt time.Time
@@ -91,11 +92,11 @@ type Event struct {
}
type EventCreate struct {
SourceID string
InstallationID int
CapabilityID string
StartedAt time.Time
MarkerBefore string
SourceID string
PluginID string
CapabilityID string
StartedAt time.Time
MarkerBefore string
}
type EventFinish struct {
@@ -154,7 +155,7 @@ type ScanWithEvent struct {
ScanRunSummary
AutoscanEventID *int64
SourceID *string
InstallationID *int
PluginID string
CapabilityID string
EventStatus EventStatus
EventCompletedAt *time.Time
+3 -2
View File
@@ -1089,8 +1089,9 @@ func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFi
if def.MediaScope != "" &&
def.MediaScope != "movie" &&
def.MediaScope != "series" &&
def.MediaScope != "episode" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', or 'episode'", ErrInvalidCatalogRequest)
def.MediaScope != "episode" &&
def.MediaScope != "audiobook" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
+423
View File
@@ -154,6 +154,51 @@ type ItemDetail struct {
EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"`
EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"`
EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"`
// Audiobook-specific detail. Present only when Type == "audiobook".
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
}
type AudiobookDetailExtension struct {
Authors []AudiobookPerson `json:"authors"`
Narrators []AudiobookPerson `json:"narrators"`
Publisher string `json:"publisher,omitempty"`
TotalDurationSeconds int `json:"total_duration_seconds"`
Series *AudiobookSeriesGroup `json:"series,omitempty"`
OtherNarrations []AudiobookNarration `json:"other_narrations"`
Related AudiobookRelatedContent `json:"related"`
}
type AudiobookPerson struct {
PersonID string `json:"person_id,omitempty"`
Name string `json:"name"`
PhotoURL string `json:"photo_url,omitempty"`
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
}
type AudiobookRelatedContent struct {
AlsoByAuthor []AudiobookRelatedItem `json:"also_by_author"`
Similar []AudiobookRelatedItem `json:"similar"`
}
type AudiobookRelatedItem struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
SeriesIndex *int `json:"series_index,omitempty"`
}
type AudiobookSeriesGroup struct {
Name string `json:"name,omitempty"`
Entries []AudiobookRelatedItem `json:"entries"`
}
type AudiobookNarration struct {
ContentID string `json:"content_id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Narrators []string `json:"narrators"`
}
// ItemUserState is per-profile viewer state included in item detail responses.
@@ -224,6 +269,7 @@ type FileVersion struct {
PresentationKind string `json:"presentation_kind,omitempty"`
PresentationGroupKey string `json:"presentation_group_key,omitempty"`
PresentationPartIndex int `json:"presentation_part_index,omitempty"`
PresentationPartTotal int `json:"presentation_part_total,omitempty"`
MultiEpisodeStart int `json:"multi_episode_start,omitempty"`
MultiEpisodeEnd int `json:"multi_episode_end,omitempty"`
EffectiveAudioTrackIndex *int `json:"effective_audio_track_index,omitempty"`
@@ -833,6 +879,9 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
}
files = FilterMediaFilesByAccess(files, filter)
if item.Type == "audiobook" {
sortAudiobookMediaFiles(files)
}
files = s.preparePlaybackFiles(ctx, files)
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
ctx,
@@ -843,6 +892,10 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
detail.OverlaySummary = overlays.BuildSummary(files)
}
if item.Type == "audiobook" {
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
}
// Series folder paths from confirmed claims when available, otherwise from
// the file links that currently belong to the item. This keeps provisional
// root-scoped series visible for manual match/admin flows without implying
@@ -952,6 +1005,351 @@ func splitCastCrew(credits []PersonCredit) ([]CastCredit, []CrewCredit) {
return cast, crew
}
func (s *DetailService) buildAudiobookExtension(
ctx context.Context,
item *models.MediaItem,
versions []FileVersion,
crew []CrewCredit,
filter AccessFilter,
) *AudiobookDetailExtension {
if item == nil {
return nil
}
totalDuration := 0
for _, version := range versions {
if version.Duration > 0 {
totalDuration += version.Duration
}
}
return &AudiobookDetailExtension{
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
Narrators: audiobookPeopleFromCrew(crew, models.PersonKindNarrator.String()),
Publisher: firstNonEmptyString(item.Studios),
TotalDurationSeconds: totalDuration,
Series: s.fetchAudiobookSeries(ctx, item.ContentID, filter),
OtherNarrations: s.fetchAudiobookOtherNarrations(ctx, item.ContentID, filter),
Related: AudiobookRelatedContent{
AlsoByAuthor: s.fetchAudiobookAlsoByAuthor(ctx, item.ContentID, filter),
Similar: s.fetchAudiobookSimilarByGenres(ctx, item.ContentID, filter),
},
}
}
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
out := make([]AudiobookPerson, 0)
for _, credit := range crew {
if credit.Job != job || strings.TrimSpace(credit.Name) == "" {
continue
}
out = append(out, AudiobookPerson{
PersonID: credit.PersonID,
Name: credit.Name,
PhotoURL: credit.PhotoURL,
PhotoThumbhash: credit.PhotoThumbhash,
})
}
return out
}
func firstNonEmptyString(values []string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func appendAudiobookItemAccessConditions(
alias string,
filter AccessFilter,
conditions *[]string,
args *[]any,
argIdx *int,
) bool {
if filter.AllowedLibraryIDs != nil {
if len(filter.AllowedLibraryIDs) == 0 {
return false
}
*conditions = append(*conditions, fmt.Sprintf(`
EXISTS (
SELECT 1 FROM media_item_libraries mil_allowed
WHERE mil_allowed.content_id = %s.content_id
AND mil_allowed.media_folder_id = ANY($%d)
)`, alias, *argIdx))
*args = append(*args, filter.AllowedLibraryIDs)
*argIdx = *argIdx + 1
}
if len(filter.DisabledLibraryIDs) > 0 {
if filter.AllowedLibraryIDs == nil {
*conditions = append(*conditions, fmt.Sprintf(`
EXISTS (
SELECT 1 FROM media_item_libraries mil_visible
WHERE mil_visible.content_id = %s.content_id
)`, alias))
}
*conditions = append(*conditions, fmt.Sprintf(`
NOT EXISTS (
SELECT 1 FROM media_item_libraries mil_disabled
WHERE mil_disabled.content_id = %s.content_id
AND mil_disabled.media_folder_id = ANY($%d)
)`, alias, *argIdx))
*args = append(*args, filter.DisabledLibraryIDs)
*argIdx = *argIdx + 1
}
ApplySectionAccessFilter(alias, AccessFilter{MaxContentRating: filter.MaxContentRating}, conditions, args, argIdx)
return true
}
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
conditions := []string{
"ip1.content_id = $1",
"ip1.kind = $2",
"m2.type = 'audiobook'",
}
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
return []AudiobookRelatedItem{}
}
query := fmt.Sprintf(`
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
FROM item_people ip1
JOIN item_people ip2
ON ip2.person_id = ip1.person_id
AND ip2.kind = ip1.kind
AND ip2.content_id <> ip1.content_id
JOIN media_items m2 ON m2.content_id = ip2.content_id
WHERE %s
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
LIMIT 12
`, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return []AudiobookRelatedItem{}
}
defer rows.Close()
out := make([]AudiobookRelatedItem, 0, 12)
seen := make(map[string]struct{})
for rows.Next() {
var item AudiobookRelatedItem
var posterPath string
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
out = append(out, item)
}
return out
}
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
conditions := []string{
"m.type = 'audiobook'",
"m.content_id <> $1",
"m.genres && (SELECT array_agg(g) FROM this_genres)",
`NOT EXISTS (
SELECT 1 FROM item_people ip
WHERE ip.content_id = m.content_id
AND ip.kind = $2
AND ip.person_id IN (SELECT person_id FROM this_author)
)`,
}
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
return []AudiobookRelatedItem{}
}
query := fmt.Sprintf(`
WITH this_genres AS (
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
),
this_author AS (
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
)
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
FROM media_items m
WHERE %s
ORDER BY
(
SELECT COUNT(*)
FROM unnest(m.genres) AS genre(value)
WHERE genre.value IN (SELECT g FROM this_genres)
) DESC,
COALESCE(m.year, 0) DESC,
LOWER(m.sort_title)
LIMIT 12
`, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return []AudiobookRelatedItem{}
}
defer rows.Close()
out := make([]AudiobookRelatedItem, 0, 12)
for rows.Next() {
var item AudiobookRelatedItem
var posterPath string
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
out = append(out, item)
}
return out
}
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return nil
}
args := []any{contentID}
argIdx := 2
conditions := []string{
"root.content_id = $1",
"m.type = 'audiobook'",
}
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
return nil
}
query := fmt.Sprintf(`
SELECT
m.content_id,
m.title,
COALESCE(m.year, 0),
COALESCE(m.poster_path, ''),
s.series_name,
s.series_index
FROM audiobook_series root
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
JOIN media_items m ON m.content_id = s.content_id
WHERE %s
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
LIMIT 30
`, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return nil
}
defer rows.Close()
var seriesName string
entries := make([]AudiobookRelatedItem, 0, 16)
for rows.Next() {
var (
item AudiobookRelatedItem
poster string
name string
index *float64
)
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &poster, &name, &index); err != nil {
return nil
}
if seriesName == "" {
seriesName = name
}
if index != nil {
n := int(*index)
if float64(n) == *index {
item.SeriesIndex = &n
}
}
item.PosterURL = s.PresignURL(ctx, poster, "featured")
entries = append(entries, item)
}
if len(entries) < 2 {
return nil
}
return &AudiobookSeriesGroup{Name: seriesName, Entries: entries}
}
func (s *DetailService) fetchAudiobookOtherNarrations(ctx context.Context, contentID string, filter AccessFilter) []AudiobookNarration {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookNarration{}
}
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
args := []any{stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator}
argIdx := 5
conditions := []string{
"m.type = 'audiobook'",
"m.content_id <> $2",
"trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)",
`EXISTS (
SELECT 1 FROM item_people ip
WHERE ip.content_id = m.content_id
AND ip.kind = $3
AND ip.person_id IN (SELECT person_id FROM this_author)
)`,
}
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
return []AudiobookNarration{}
}
query := fmt.Sprintf(`
WITH this_book AS (
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
FROM media_items WHERE content_id = $2
),
this_author AS (
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
)
SELECT
m.content_id,
m.title,
COALESCE(m.year, 0),
COALESCE(string_agg(DISTINCT NULLIF(BTRIM(p.name), ''), '|||'), '') AS narrators
FROM media_items m
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
LEFT JOIN people p ON p.id = ipn.person_id
WHERE %s
GROUP BY m.content_id, m.title, m.year
ORDER BY m.year DESC NULLS LAST, m.title
LIMIT 8
`, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return []AudiobookNarration{}
}
defer rows.Close()
out := make([]AudiobookNarration, 0, 4)
for rows.Next() {
var item AudiobookNarration
var narrators string
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &narrators); err != nil {
return []AudiobookNarration{}
}
item.Narrators = splitDelimitedNames(narrators)
out = append(out, item)
}
return out
}
func splitDelimitedNames(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
parts := strings.Split(raw, "|||")
out := make([]string, 0, len(parts))
for _, part := range parts {
if value := strings.TrimSpace(part); value != "" {
out = append(out, value)
}
}
return out
}
func seriesFolderPathsFromFiles(files []*models.MediaFile) []string {
if len(files) == 0 {
return nil
@@ -1567,6 +1965,30 @@ func markerFromRange(start, end *float64) *Marker {
return &Marker{Start: *start, End: *end}
}
func sortAudiobookMediaFiles(files []*models.MediaFile) {
sort.SliceStable(files, func(i, j int) bool {
a, b := files[i], files[j]
if a == nil || b == nil {
return a != nil
}
if a.PresentationPartIndex > 0 || b.PresentationPartIndex > 0 {
if a.PresentationPartIndex != b.PresentationPartIndex {
if a.PresentationPartIndex == 0 {
return false
}
if b.PresentationPartIndex == 0 {
return true
}
return a.PresentationPartIndex < b.PresentationPartIndex
}
}
if a.FilePath != b.FilePath {
return strings.Compare(a.FilePath, b.FilePath) < 0
}
return a.ID < b.ID
})
}
func (s *DetailService) buildPlaybackInfo(
ctx context.Context,
files []*models.MediaFile,
@@ -1622,6 +2044,7 @@ func (s *DetailService) buildPlaybackInfo(
PresentationKind: f.PresentationKind,
PresentationGroupKey: f.PresentationGroupKey,
PresentationPartIndex: f.PresentationPartIndex,
PresentationPartTotal: f.PresentationPartTotal,
MultiEpisodeStart: f.MultiEpisodeStart,
MultiEpisodeEnd: f.MultiEpisodeEnd,
EffectiveAudioTrackIndex: intPtr(effectiveAudioSelection.Index),
+42
View File
@@ -0,0 +1,42 @@
package catalog
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestSortAudiobookMediaFilesPreservesPresentationPartOrder(t *testing.T) {
files := []*models.MediaFile{
{ID: 30, FilePath: "/books/book/03.mp3", PresentationPartIndex: 3},
{ID: 10, FilePath: "/books/book/01.mp3", PresentationPartIndex: 1},
{ID: 20, FilePath: "/books/book/02.mp3", PresentationPartIndex: 2},
}
sortAudiobookMediaFiles(files)
got := []int{files[0].ID, files[1].ID, files[2].ID}
want := []int{10, 20, 30}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sorted IDs = %v, want %v", got, want)
}
}
}
func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
files := []*models.MediaFile{
{ID: 20, FilePath: "/books/book/02.mp3"},
{ID: 10, FilePath: "/books/book/01.mp3"},
}
sortAudiobookMediaFiles(files)
got := []int{files[0].ID, files[1].ID}
want := []int{10, 20}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sorted IDs = %v, want %v", got, want)
}
}
}
+3 -2
View File
@@ -185,8 +185,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
if normalized.MediaScope != "" &&
normalized.MediaScope != "movie" &&
normalized.MediaScope != "series" &&
normalized.MediaScope != "episode" {
return fmt.Errorf("media_scope must be 'movie', 'series', or 'episode'")
normalized.MediaScope != "episode" &&
normalized.MediaScope != "audiobook" {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
}
if normalized.Match != "all" && normalized.Match != "any" {
+12
View File
@@ -60,6 +60,18 @@ func TestValidate_EpisodeMediaScope(t *testing.T) {
}
}
func TestValidate_AudiobookMediaScope(t *testing.T) {
qd := QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []QueryGroup{},
Sort: QuerySort{Field: "title", Order: "asc"},
}
if err := qd.Validate(); err != nil {
t.Fatalf("expected audiobook media scope to be valid, got %v", err)
}
}
func TestValidateWithSortScope_RejectsPersonalizedSortWithoutProfileScope(t *testing.T) {
qd := QueryDefinition{
Match: "all",
+1 -1
View File
@@ -32,7 +32,7 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
runExtract = runFFmpegFrameExtract
}
resolvedAccel := playback.ResolveHWAccel(opts.HWAccel)
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
resolvedDevice := opts.HWDevice
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
resolvedDevice = playback.PickRenderDevice("")
+1 -1
View File
@@ -652,7 +652,7 @@ func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekS
func (s *Service) resolveHWConfig() (string, string) {
s.hwResolveOnce.Do(func() {
s.resolvedHWAccel = playback.ResolveHWAccel(s.hwAccel)
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
s.resolvedHWDevice = s.hwDevice
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
s.resolvedHWDevice = playback.PickRenderDevice("")
+3 -4
View File
@@ -198,13 +198,12 @@ type authConfigRaw struct {
RefreshTokenExpiry string `yaml:"refresh_token_expiry"`
}
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener
// settings. The listener binds its own port (default :13378, ABS's
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener setting.
// The listener binds its own port (:13378, ABS's
// conventional port) and serves the full ABS protocol — login, libraries,
// items, sessions — without colliding with silo's SPA on the main listener.
type AudiobookshelfCompatConfig struct {
Listen string `yaml:"listen"`
PublicURL string `yaml:"public_url"`
Listen string `yaml:"-"`
}
// JellyfinCompatConfig holds compatibility proxy settings.
+4 -6
View File
@@ -356,15 +356,13 @@ func LoadFromDB(m map[string]string) (*Config, error) {
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
// listener off unless the audiobooks feature flag is explicitly enabled.
audiobooksEnabled, err := boolOr(m, "audiobooks.enabled", false)
// listener off unless ABS compatibility is explicitly enabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
if err != nil {
return nil, err
}
if audiobooksEnabled {
// Default :13378 mirrors the real Audiobookshelf server convention.
cfg.AudiobookshelfCompat.Listen = stringOr(m, "audiobookshelf_compat.listen", ":13378")
cfg.AudiobookshelfCompat.PublicURL = stringOr(m, "audiobookshelf_compat.public_url", "http://127.0.0.1:13378")
if absCompatEnabled {
cfg.AudiobookshelfCompat.Listen = ":13378"
}
// JellyfinCompat
+3 -3
View File
@@ -34,8 +34,8 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
}
func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobooks.enabled": "false"})
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
@@ -43,7 +43,7 @@ func TestLoadFromDBAudiobooksFeatureFlagGatesCompatListener(t *testing.T) {
t.Fatalf("disabled audiobooks listener = %q, want empty", cfg.AudiobookshelfCompat.Listen)
}
cfg, err = LoadFromDB(map[string]string{"audiobooks.enabled": "true"})
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "true"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
+54
View File
@@ -0,0 +1,54 @@
package jellycompat
import (
"context"
"log/slog"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/catalog"
)
// ScopeResolver resolves a viewer request into an effective access scope.
// Implemented by *access.Resolver.
type ScopeResolver interface {
Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error)
}
// NewScopeAccessFilter returns an AccessFilterResolver backed by the shared
// access scope resolver, so account-level library restrictions
// (users.library_ids), profile library restrictions, user-disabled
// libraries, content-rating ceilings, and playback-quality ceilings all
// apply to the Jellyfin-compat API exactly as they do to the native API.
//
// PIN verification is skipped because compat login already verified the
// profile PIN (password#pin convention). Resolution failures fail closed:
// the viewer gets an empty library allowlist rather than full access.
func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
return func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
scope, err := resolver.Resolve(ctx, access.ResolveInput{
UserID: userID,
ProfileID: profileID,
SkipPINVerification: true,
})
if err != nil {
slog.Warn("jellycompat: access scope resolution failed; denying library access",
"user_id", userID,
"profile_id", profileID,
"error", err,
)
return catalog.AccessFilter{
AllowedLibraryIDs: []int{},
UserID: userID,
ProfileID: profileID,
}
}
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: userID,
ProfileID: profileID,
}
}
}
+177
View File
@@ -0,0 +1,177 @@
package jellycompat
import (
"context"
"errors"
"reflect"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
type stubScopeResolver struct {
scope access.Scope
err error
input access.ResolveInput
}
func (s *stubScopeResolver) Resolve(_ context.Context, input access.ResolveInput) (access.Scope, error) {
s.input = input
return s.scope, s.err
}
func TestScopeAccessFilterMapsScope(t *testing.T) {
resolver := &stubScopeResolver{
scope: access.Scope{
UserID: 7,
ProfileID: "profile-1",
AllowedLibraryIDs: []int{2, 19},
LibrariesRestricted: true,
MaxContentRating: "PG-13",
MaxPlaybackQuality: "1080p",
},
}
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
if !resolver.input.SkipPINVerification {
t.Fatal("expected SkipPINVerification=true (PIN is verified at compat login)")
}
if resolver.input.UserID != 7 || resolver.input.ProfileID != "profile-1" {
t.Fatalf("unexpected resolve input: %+v", resolver.input)
}
if !reflect.DeepEqual(filter.AllowedLibraryIDs, []int{2, 19}) {
t.Fatalf("AllowedLibraryIDs = %v, want [2 19]", filter.AllowedLibraryIDs)
}
if filter.MaxContentRating != "PG-13" {
t.Fatalf("MaxContentRating = %q, want PG-13", filter.MaxContentRating)
}
if filter.MaxPlaybackQuality != "1080p" {
t.Fatalf("MaxPlaybackQuality = %q, want 1080p", filter.MaxPlaybackQuality)
}
if filter.UserID != 7 || filter.ProfileID != "profile-1" {
t.Fatalf("identity not propagated: %+v", filter)
}
}
func TestScopeAccessFilterUnrestrictedScope(t *testing.T) {
resolver := &stubScopeResolver{
scope: access.Scope{
UserID: 1,
DisabledLibraryIDs: []int{7},
},
}
filter := NewScopeAccessFilter(resolver)(context.Background(), 1, "profile-1")
if filter.AllowedLibraryIDs != nil {
t.Fatalf("AllowedLibraryIDs = %v, want nil (unrestricted)", filter.AllowedLibraryIDs)
}
if !reflect.DeepEqual(filter.DisabledLibraryIDs, []int{7}) {
t.Fatalf("DisabledLibraryIDs = %v, want [7]", filter.DisabledLibraryIDs)
}
}
func TestScopeAccessFilterFailsClosed(t *testing.T) {
resolver := &stubScopeResolver{err: errors.New("boom")}
filter := NewScopeAccessFilter(resolver)(context.Background(), 7, "profile-1")
if filter.AllowedLibraryIDs == nil || len(filter.AllowedLibraryIDs) != 0 {
t.Fatalf("AllowedLibraryIDs = %v, want empty non-nil allowlist (deny all)", filter.AllowedLibraryIDs)
}
}
type stubFolderSource struct {
enabled []*models.MediaFolder
byIDs []*models.MediaFolder
listByIDsArgs []int
}
func (s *stubFolderSource) GetEnabled(context.Context) ([]*models.MediaFolder, error) {
return s.enabled, nil
}
func (s *stubFolderSource) ListByIDs(_ context.Context, ids []int) ([]*models.MediaFolder, error) {
s.listByIDsArgs = ids
return s.byIDs, nil
}
func TestListUserLibrariesRestrictedAllowlist(t *testing.T) {
folders := &stubFolderSource{
byIDs: []*models.MediaFolder{
{ID: 2, Name: "TV Shows", Type: "series"},
{ID: 19, Name: "Movies", Type: "movies"},
},
}
svc := &directContentService{
folderRepo: folders,
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
scope: access.Scope{
AllowedLibraryIDs: []int{2, 19},
LibrariesRestricted: true,
},
}),
}
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
if err != nil {
t.Fatalf("ListUserLibraries: %v", err)
}
if !reflect.DeepEqual(folders.listByIDsArgs, []int{2, 19}) {
t.Fatalf("ListByIDs called with %v, want [2 19]", folders.listByIDsArgs)
}
if len(libraries) != 2 {
t.Fatalf("got %d libraries, want 2", len(libraries))
}
}
func TestListUserLibrariesFiltersDisabled(t *testing.T) {
svc := &directContentService{
folderRepo: &stubFolderSource{
enabled: []*models.MediaFolder{
{ID: 2, Name: "TV Shows", Type: "series"},
{ID: 7, Name: "XXX", Type: "movies"},
{ID: 19, Name: "Movies", Type: "movies"},
},
},
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
scope: access.Scope{
DisabledLibraryIDs: []int{7},
},
}),
}
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 1, ProfileID: "profile-1"})
if err != nil {
t.Fatalf("ListUserLibraries: %v", err)
}
if len(libraries) != 2 {
t.Fatalf("got %d libraries, want 2 (disabled library not filtered)", len(libraries))
}
for _, lib := range libraries {
if lib.ID == 7 {
t.Fatal("user-disabled library 7 still present in views list")
}
}
}
func TestListUserLibrariesEmptyAllowlistDeniesAll(t *testing.T) {
svc := &directContentService{
accessFilter: NewScopeAccessFilter(&stubScopeResolver{
scope: access.Scope{
AllowedLibraryIDs: []int{},
LibrariesRestricted: true,
},
}),
}
libraries, err := svc.ListUserLibraries(context.Background(), &Session{StreamAppUserID: 7, ProfileID: "profile-1"})
if err != nil {
t.Fatalf("ListUserLibraries: %v", err)
}
if len(libraries) != 0 {
t.Fatalf("got %d libraries, want 0 for empty allowlist", len(libraries))
}
}
@@ -142,6 +142,7 @@ func TestBuildPlaybackSource_SeedsRequestedAudioStreamIndex(t *testing.T) {
version,
DeviceProfile{},
playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(requestedAudioStreamIndex)},
true,
)
if source.SelectedAudioStreamIndex == nil {
+170 -3
View File
@@ -46,6 +46,14 @@ type itemAccessSource interface {
GetByIDs(ctx context.Context, contentIDs []string) ([]*models.MediaItem, error)
}
// folderListSource is the subset of *catalog.FolderRepository that
// directContentService relies on. Defined as an interface so tests can
// substitute a stub without standing up a Postgres pool.
type folderListSource interface {
GetEnabled(ctx context.Context) ([]*models.MediaFolder, error)
ListByIDs(ctx context.Context, ids []int) ([]*models.MediaFolder, error)
}
// seasonListSource is the subset of *catalog.SeasonRepository that
// directContentService relies on.
type seasonListSource interface {
@@ -61,6 +69,7 @@ type seasonListSource interface {
type episodeListSource interface {
ListBySeason(ctx context.Context, seriesID string, seasonNum int) ([]*models.Episode, error)
ListBySeriesGroupedBySeason(ctx context.Context, seriesID string) (map[int][]*models.Episode, error)
ListBySeriesIDs(ctx context.Context, seriesIDs []string) (map[string][]*models.Episode, error)
}
// directContentService implements ContentService by calling catalog repos directly.
@@ -70,7 +79,7 @@ type directContentService struct {
seasonRepo seasonListSource
episodeRepo episodeListSource
detailSvc *catalog.DetailService
folderRepo *catalog.FolderRepository
folderRepo folderListSource
storeProvider userstore.UserStoreProvider
accessFilter AccessFilterResolver
posterPresigner LibraryPosterPresigner
@@ -83,7 +92,7 @@ func newDirectContentService(
seasonRepo *catalog.SeasonRepository,
episodeRepo *catalog.EpisodeRepository,
detailSvc *catalog.DetailService,
folderRepo *catalog.FolderRepository,
folderRepo folderListSource,
storeProvider userstore.UserStoreProvider,
accessFilter AccessFilterResolver,
) *directContentService {
@@ -135,7 +144,12 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
var folders []*models.MediaFolder
var err error
if len(filter.AllowedLibraryIDs) > 0 {
if filter.AllowedLibraryIDs != nil {
// A non-nil allowlist means the viewer is restricted; an empty
// allowlist grants access to no libraries at all.
if len(filter.AllowedLibraryIDs) == 0 {
return []upstreamUserLibrary{}, nil
}
folders, err = s.folderRepo.ListByIDs(ctx, filter.AllowedLibraryIDs)
} else {
folders, err = s.folderRepo.GetEnabled(ctx)
@@ -144,8 +158,16 @@ func (s *directContentService) ListUserLibraries(ctx context.Context, session *S
return nil, fmt.Errorf("list libraries: %w", err)
}
disabled := make(map[int]struct{}, len(filter.DisabledLibraryIDs))
for _, id := range filter.DisabledLibraryIDs {
disabled[id] = struct{}{}
}
libraries := make([]upstreamUserLibrary, 0, len(folders))
for _, f := range folders {
if _, ok := disabled[f.ID]; ok {
continue
}
lib := upstreamUserLibrary{
ID: f.ID,
Name: f.Name,
@@ -265,6 +287,18 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
if len(collected) > requestedLimit {
collected = collected[:requestedLimit]
}
// The handler's resolveUserStateForContentIDs overlay only covers items
// with their own progress rows, which a series never has — aggregate
// series watch state here so library views carry Played/UnplayedItemCount.
// The is_played path already did this via enrichListItemsUserData.
// That same absence is what keeps this safe from the handler overlay:
// userDataDTO only overrides item-level UserData when a direct progress
// row exists, and one never does for a series id.
if isPlayedFilter == "" {
s.enrichSeriesUserData(ctx, session, collected)
}
if totalKnown {
hasMore = totalFromCatalog > requestedOffset+requestedLimit
}
@@ -347,6 +381,17 @@ func (s *directContentService) GetItemDetail(ctx context.Context, session *Sessi
IsInProgress: progress.PositionSeconds > 0 && !progress.Completed,
}
}
// A series never has a progress row of its own, so roll watch
// state up from its episodes (mirrors applySeasonUserData) to
// give clients Played/UnplayedItemCount at the series level.
if result.UserData == nil && strings.EqualFold(result.Type, "series") && s.episodeRepo != nil {
if episodesBySeries, epErr := s.episodeRepo.ListBySeriesIDs(ctx, []string{contentID}); epErr == nil {
episodes := episodesBySeries[contentID]
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, modelEpisodeContentIDs(episodes))
result.UserData = seriesUserDataFromEpisodes(episodes, progressMap)
}
}
}
}
@@ -545,6 +590,128 @@ func (s *directContentService) enrichListItemsUserData(ctx context.Context, sess
items[i].UserData = seasonUserDataFromProgress(progress)
}
}
s.enrichSeriesListUserData(ctx, session, store, items)
}
// enrichSeriesUserData is enrichSeriesListUserData with store acquisition,
// for callers that haven't already resolved the user store.
func (s *directContentService) enrichSeriesUserData(ctx context.Context, session *Session, items []upstreamListItem) {
if s.storeProvider == nil || len(items) == 0 {
return
}
store, err := s.userStore(ctx, session)
if err != nil {
return
}
s.enrichSeriesListUserData(ctx, session, store, items)
}
// enrichSeriesListUserData fills aggregated user data for series rows in a
// list response. A series never has a progress row of its own, so Played and
// UnplayedItemCount are rolled up from episode progress, mirroring
// applySeasonUserData semantics.
func (s *directContentService) enrichSeriesListUserData(ctx context.Context, session *Session, store userstore.UserStore, items []upstreamListItem) {
if s.episodeRepo == nil {
return
}
seriesIDs := make([]string, 0, len(items))
for i := range items {
if items[i].UserData == nil && items[i].ContentID != "" && strings.EqualFold(items[i].Type, "series") {
seriesIDs = append(seriesIDs, items[i].ContentID)
}
}
if len(seriesIDs) == 0 {
return
}
// Cap the rollup so a max-limit browse (compatBrowseMaxLimit) of a
// series-only library can't materialize hundreds of thousands of episode
// rows in one request. Series past the cap keep a nil UserData — the same
// shape they had before series aggregation existed. Real clients page at
// 20-100 items, well under the cap.
const maxSeriesUserDataRollups = 250
if len(seriesIDs) > maxSeriesUserDataRollups {
seriesIDs = seriesIDs[:maxSeriesUserDataRollups]
}
episodesBySeries, err := s.episodeRepo.ListBySeriesIDs(ctx, seriesIDs)
if err != nil {
return
}
episodeIDs := make([]string, 0, 256)
for _, episodes := range episodesBySeries {
episodeIDs = append(episodeIDs, modelEpisodeContentIDs(episodes)...)
}
progressMap := chunkedProgressByMediaItems(ctx, store, session.ProfileID, episodeIDs)
for i := range items {
if items[i].UserData != nil || !strings.EqualFold(items[i].Type, "series") {
continue
}
if episodes, ok := episodesBySeries[items[i].ContentID]; ok {
items[i].UserData = seriesUserDataFromEpisodes(episodes, progressMap)
}
}
}
// seriesUserDataFromEpisodes computes WatchedCount/UnplayedCount/
// InProgressCount/Played for a whole series from a pre-fetched progressMap.
// Pure function — no I/O. Counting semantics match the native API's series
// rollup (all episodes including specials; in-progress = started but not
// completed).
func seriesUserDataFromEpisodes(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) *catalog.SeasonUserData {
watched := 0
unplayed := 0
inProgress := 0
for _, ep := range episodes {
if ep == nil {
continue
}
progress, ok := progressMap[ep.ContentID]
if ok && progress.Completed {
watched++
continue
}
if ok && progress.PositionSeconds > 0 {
inProgress++
}
unplayed++
}
return &catalog.SeasonUserData{
WatchedCount: watched,
UnplayedCount: unplayed,
InProgressCount: inProgress,
Played: unplayed == 0 && len(episodes) > 0,
}
}
// modelEpisodeContentIDs returns the non-empty content ids of the given episodes.
func modelEpisodeContentIDs(episodes []*models.Episode) []string {
ids := make([]string, 0, len(episodes))
for _, ep := range episodes {
if ep != nil && ep.ContentID != "" {
ids = append(ids, ep.ContentID)
}
}
return ids
}
// chunkedProgressByMediaItems batches ListProgressByMediaItems calls when a
// series list expands to thousands of episode ids: per-user stores may be
// SQLite-backed (999 bind-variable default), and chunking also keeps
// Postgres IN-lists at a sane size. Returns an empty map (never nil); chunks
// that error are skipped.
func chunkedProgressByMediaItems(ctx context.Context, store userstore.UserStore, profileID string, mediaItemIDs []string) map[string]userstore.WatchProgress {
const chunkSize = 500
result := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
for start := 0; start < len(mediaItemIDs); start += chunkSize {
chunk, err := store.ListProgressByMediaItems(ctx, profileID, mediaItemIDs[start:min(start+chunkSize, len(mediaItemIDs))])
if err != nil {
continue
}
for id, progress := range chunk {
result[id] = progress
}
}
return result
}
// enrichSeasonUserData adds aggregated user data for a season. Used by
+112 -19
View File
@@ -1183,27 +1183,45 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
return
}
// The scan stays on list-level data (per-item GetItemDetail fanout over
// the FULL next-up list was 5N DB roundtrips for users with long lists);
// only the sliced page below is upgraded to detail when the client asked
// for detail-level Fields — see upgradeProgressPageToDetail for why the
// listing's MediaSources are load-bearing for Infuse/SenPlayer.
pageIDs := make([]string, 0, len(results))
items := make([]baseItemDTO, 0, len(results))
for _, res := range results {
target, ok := episodeTargets[res.ContentID]
if !ok {
continue
}
// NextUp deliberately serves list-level data even when the client
// requests Fields=People|Chapters|MediaStreams|MediaSources, mirroring
// the Resume hydrator (handlers_items.go ~L1721). Per-item
// GetItemDetail fanout was 5N DB roundtrips for users with long
// next-up lists, and the detail data was never load-bearing for the
// row UX — clients refetch MediaSources via /Items/{id}/PlaybackInfo
// on play.
dto := h.mapper.itemFromList(target.Item, favorites[res.ContentID], progress[res.ContentID], query.requestedFields)
h.applyCompatEpisodeTarget(&dto, target)
stubDetailListFields(&dto, query.requestedFields)
pageIDs = append(pageIDs, res.ContentID)
items = append(items, dto)
}
total := len(items)
items = sliceBaseItems(items, query.startIndex, query.limit)
pageIDs = slicePageIDs(pageIDs, query.startIndex, query.limit)
if query.needsDetailFields {
for i := range items {
if i >= maxDetailUpgrades || i >= len(pageIDs) {
break
}
detail, detailErr := h.content.GetItemDetail(r.Context(), session, pageIDs[i], nil)
if detailErr != nil {
continue
}
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, favorites[pageIDs[i]], progress[pageIDs[i]], query.requestedFields)
if target, ok := episodeTargets[pageIDs[i]]; ok {
h.applyCompatEpisodeTarget(&dto, target)
}
items[i] = dto
}
}
applyImageTypeLimit(items, query.imageTypeLimit)
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
@@ -1212,6 +1230,22 @@ func (h *ItemsHandler) writeNextUpResponse(w http.ResponseWriter, r *http.Reques
})
}
// slicePageIDs applies sliceBaseItems' bounds to the parallel content-id
// slice so page positions stay aligned after StartIndex/Limit slicing.
func slicePageIDs(ids []string, startIndex, limit int) []string {
if startIndex < 0 {
startIndex = 0
}
if startIndex >= len(ids) {
return []string{}
}
if limit <= 0 {
limit = len(ids)
}
end := min(startIndex+limit, len(ids))
return ids[startIndex:end]
}
// HandleUpcoming serves GET /Shows/Upcoming.
//
// Android TV's show-detail page calls this endpoint to populate a single
@@ -1722,10 +1756,20 @@ func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Reque
}
type progressHydratedItem struct {
itemType string
dto baseItemDTO
itemType string
contentID string
isFavorite bool
entry upstreamProgress
target *compatEpisodeTarget
dto baseItemDTO
}
// maxDetailUpgrades caps how many returned-page items get re-mapped through
// the per-item detail path when the client requested detail-level Fields.
// Entries past the cap keep their stubbed list-level DTO. Guardrail against
// absurd client limits — normal Resume/NextUp requests are 20-40 items.
const maxDetailUpgrades = 100
func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, status string, query itemsQuery, typeSet map[string]bool, libraryID *int) ([]baseItemDTO, int, error) {
if len(typeSet) == 0 && libraryID == nil && !query.enableTotalRecordCount {
batchSize := min(max(query.limit*2, 48), 200)
@@ -1733,7 +1777,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
batchSize = 48
}
result := make([]baseItemDTO, 0, query.limit)
result := make([]progressHydratedItem, 0, query.limit)
offset := query.startIndex
for {
progressEntries, err := h.userData.ListProgress(ctx, session, status, batchSize, offset)
@@ -1752,14 +1796,14 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
if len(result) >= query.limit {
break
}
result = append(result, item.dto)
result = append(result, item)
}
if len(result) >= query.limit || len(progressEntries) < batchSize {
break
}
offset += len(progressEntries)
}
return result, 0, nil
return h.finishProgressPage(ctx, session, result, query, libraryID), 0, nil
}
batchSize := min(max(query.limit*3, 48), 200)
@@ -1767,7 +1811,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
batchSize = 48
}
items := make([]baseItemDTO, 0, query.limit)
items := make([]progressHydratedItem, 0, query.limit)
matchedCount := 0
offset := 0
@@ -1789,7 +1833,7 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
continue
}
if matchedCount >= query.startIndex && len(items) < query.limit {
items = append(items, item.dto)
items = append(items, item)
}
matchedCount++
}
@@ -1807,7 +1851,49 @@ func (h *ItemsHandler) loadProgressPage(ctx context.Context, session *Session, s
if query.enableTotalRecordCount {
total = matchedCount
}
return items, total, nil
return h.finishProgressPage(ctx, session, items, query, libraryID), total, nil
}
// finishProgressPage upgrades the returned page to detail-mapped DTOs when
// the client asked for detail-level Fields, then unwraps to the DTO slice.
func (h *ItemsHandler) finishProgressPage(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) []baseItemDTO {
h.upgradeProgressPageToDetail(ctx, session, page, query, libraryID)
dtos := make([]baseItemDTO, 0, len(page))
for _, item := range page {
dtos = append(dtos, item.dto)
}
return dtos
}
// upgradeProgressPageToDetail re-maps the returned page of progress rows
// through the detail path when the client requested detail-level Fields
// (e.g. MediaSources). The scan loop in loadProgressPage stays on list-level
// data so type filtering over long in-progress lists never fans out — the
// per-ENTRY fanout was the 38s timeout in error-report-2026-05-08.md §6 —
// but the returned page is bounded by the request limit, so this costs the
// same as HandleLatest's existing per-item detail path. The listing's
// MediaSources ARE load-bearing for some clients: Infuse and SenPlayer build
// their Continue Watching rows from them and discard entries served only the
// list-level stub.
func (h *ItemsHandler) upgradeProgressPageToDetail(ctx context.Context, session *Session, page []progressHydratedItem, query itemsQuery, libraryID *int) {
if !query.needsDetailFields {
return
}
for i := range page {
if i >= maxDetailUpgrades {
break
}
detail, err := h.content.GetItemDetail(ctx, session, page[i].contentID, libraryID)
if err != nil {
continue
}
h.rememberDetailImages(*detail)
dto := h.mapper.itemFromDetailWithFields(*detail, page[i].isFavorite, &page[i].entry, query.requestedFields)
if page[i].target != nil {
h.applyCompatEpisodeTarget(&dto, *page[i].target)
}
page[i].dto = dto
}
}
func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Session, entries []upstreamProgress, fields map[string]bool, libraryID *int) ([]progressHydratedItem, error) {
@@ -1843,8 +1929,11 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
dto := h.mapper.itemFromList(item, favorites[entry.MediaItemID], &entry, fields)
stubDetailListFields(&dto, fields)
result = append(result, progressHydratedItem{
itemType: strings.ToLower(item.Type),
dto: dto,
itemType: strings.ToLower(item.Type),
contentID: entry.MediaItemID,
isFavorite: favorites[entry.MediaItemID],
entry: entry,
dto: dto,
})
continue
}
@@ -1853,8 +1942,12 @@ func (h *ItemsHandler) hydrateProgressItems(ctx context.Context, session *Sessio
h.applyCompatEpisodeTarget(&dto, target)
stubDetailListFields(&dto, fields)
result = append(result, progressHydratedItem{
itemType: "episode",
dto: dto,
itemType: "episode",
contentID: entry.MediaItemID,
isFavorite: favorites[entry.MediaItemID],
entry: entry,
target: &target,
dto: dto,
})
}
}
+29 -1
View File
@@ -20,6 +20,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
@@ -154,6 +155,22 @@ func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.Prog
return t
}
var errTranscode4KDisallowed = errors.New("4k video transcode disallowed by server settings")
// allow4KVideoTranscode reads the allow_4k_transcode server setting,
// defaulting to deny like the native playback handler.
func (h *PlaybackHandler) allow4KVideoTranscode(ctx context.Context) bool {
if h.SettingsRepo == nil {
return false
}
v, _ := h.SettingsRepo.Get(ctx, "allow_4k_transcode")
return v == "true"
}
func is4KResolution(res string) bool {
return access.CompareQuality(res, "2160p") >= 0
}
// NewPlaybackHandler creates a playback handler.
func NewPlaybackHandler(
cfg *config.Config,
@@ -300,6 +317,9 @@ func (h *PlaybackHandler) startRemoteTranscode(
initialSeekSeconds float64,
transcodeNodeURL string,
) error {
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
return errTranscode4KDisallowed
}
if d := float64(source.Version.Duration); d > 0 && initialSeekSeconds > d {
initialSeekSeconds = d
}
@@ -416,8 +436,9 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
sourceDTOs := make([]mediaSourceDTO, 0, len(detail.Versions))
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
for _, version := range detail.Versions {
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req)
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
continue
}
@@ -513,6 +534,7 @@ func (h *PlaybackHandler) buildPlaybackSource(
version catalog.FileVersion,
profile DeviceProfile,
req playbackInfoRequest,
allow4KTranscode bool,
) PlaybackMediaSource {
sourceID := h.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
enableDirectPlay := boolDefault(req.EnableDirectPlay, true)
@@ -531,6 +553,12 @@ func (h *PlaybackHandler) buildPlaybackSource(
allowAudioCopy &&
audioSupported
supportsTranscoding := boolDefault(req.EnableTranscoding, true) && profile.SupportsTranscoding(version)
// Don't offer full video encodes of 4K sources when allow_4k_transcode is
// off. Audio-only transcodes (transcodeAudio) stream-copy the video and
// stay available.
if supportsTranscoding && !transcodeAudio && !allow4KTranscode && is4KResolution(version.Resolution) {
supportsTranscoding = false
}
audioIndex := defaultAudioStreamIndex(version)
subtitleIndex := defaultSubtitleStreamIndex(version)
@@ -25,11 +25,11 @@ func (s *panicOnGetItemDetailContent) GetItemDetail(_ context.Context, _ *Sessio
// Fields=People|Chapters|MediaStreams|MediaSources, producing the 38s timeout
// observed in production for users with large in-progress lists.
//
// The function now serves list-level data unconditionally and stubs the four
// detail-only fields with empty (but non-nil) collections via
// stubDetailListFields. Standard Jellyfin clients refetch MediaSources via
// /Items/{id}/PlaybackInfo on play, so the detail data was never load-bearing
// for the Resume row UX.
// hydrateProgressItems is the SCAN phase and must stay detail-free: it runs
// over up to 200 entries per loop iteration purely for type filtering. The
// detail data clients need (real MediaSources — load-bearing for Infuse and
// SenPlayer Continue Watching rows) is served by upgradeProgressPageToDetail,
// which runs only on the returned page, bounded by the request limit.
func TestHydrateProgressItems_DoesNotCallGetItemDetail(t *testing.T) {
codec := NewResourceIDCodec()
@@ -65,6 +65,10 @@ func (c *countingEpisodeListSource) ListBySeriesGroupedBySeason(_ context.Contex
return out, nil
}
func (c *countingEpisodeListSource) ListBySeriesIDs(_ context.Context, _ []string) (map[string][]*models.Episode, error) {
return map[string][]*models.Episode{}, nil
}
// TestListSeasons_UsesBatchEpisodeFetch verifies ListSeasons makes exactly
// one ListBySeriesGroupedBySeason call (replacing N per-season ListBySeason
// calls) and exactly one ListProgressByMediaItems call (replacing N
+35 -7
View File
@@ -204,8 +204,11 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
// The values are minimal: each carries the required (non-omitempty) fields
// only. Clients that consume this data downstream will see a single source
// with a stub ID, a single Video stream at index 0, and a single placeholder
// person; standard Jellyfin clients refetch real MediaSources via
// /Items/{id}/PlaybackInfo on play, so this stub is never load-bearing.
// person. The stub is a FALLBACK, not a no-op: Infuse and SenPlayer build
// their Continue Watching rows from the listing's MediaSources, so pages
// within the detail-upgrade cap get real sources (upgradeProgressPageToDetail)
// and only overflow/error entries see the stub — which must therefore still
// look playable and serialize collections as empty, never null.
var (
stubDetailPerson = personDTO{ID: "0", Name: ""}
stubDetailMediaSource = mediaSourceDTO{ID: "0"}
@@ -214,10 +217,11 @@ var (
// stubDetailListFields sets the four detail-only fields on a list-mapped DTO
// to single-element placeholder slices when the client requested them via
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume
// hydrator, which deliberately skips per-item GetItemDetail fanout; clients
// that need real MediaSources refetch them via /Items/{id}/PlaybackInfo on
// play.
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume/NextUp
// scan phase, which deliberately skips per-item GetItemDetail fanout; the
// returned page is then re-mapped through the real detail path
// (upgradeProgressPageToDetail), so these stubs reach clients only for
// entries past the detail-upgrade cap or whose detail fetch failed.
func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
if len(fields) == 0 {
return
@@ -234,7 +238,31 @@ func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
dto.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
}
if fields["mediasources"] && dto.MediaSources == nil {
dto.MediaSources = []mediaSourceDTO{stubDetailMediaSource}
// The stub must still look PLAYABLE: some clients (SenPlayer) decide
// whether to render a Resume row entry from the listing's
// MediaSources, and an all-false playability stub makes them drop
// every item. Keep the stub ID ("0" — never a registered media source
// owner) but mirror the shape detailMediaSourceDTO produces; the real
// source set is still refetched via /Items/{id}/PlaybackInfo on play.
src := stubDetailMediaSource
src.Protocol = "File"
src.Type = "Default"
src.VideoType = "VideoFile"
src.Name = dto.Name
src.RunTimeTicks = dto.RunTimeTicks
src.SupportsTranscoding = true
src.SupportsDirectStream = true
src.SupportsDirectPlay = true
src.SupportsProbing = true
src.TranscodingSubProtocol = "hls"
src.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
// Real servers never serialize these as JSON null (always []/{});
// strict client deserializers fail the whole response on a null
// array, which empties the Resume/NextUp rows entirely.
src.Formats = []string{}
src.RequiredHTTPHeaders = map[string]string{}
src.MediaAttachments = []map[string]any{}
dto.MediaSources = []mediaSourceDTO{src}
}
}
@@ -157,3 +157,59 @@ func TestStubDetailListFields_FreshChapterMapPerCall(t *testing.T) {
t.Errorf("mutating dto a's chapter map leaked into dto b: b.Chapters[0][Name] = %v", got)
}
}
// TestStubDetailListFields_MediaSourceStubLooksPlayable locks in that the
// MediaSources stub advertises playability. Some clients (SenPlayer) decide
// whether to render a Resume row entry from the listing's MediaSources; an
// all-false playability stub made them drop every entry (the Continue
// Watching row showed empty while item-page resume worked fine).
func TestStubDetailListFields_MediaSourceStubLooksPlayable(t *testing.T) {
dto := baseItemDTO{Name: "Pilot", RunTimeTicks: 34800000000}
stubDetailListFields(&dto, map[string]bool{"mediasources": true})
if len(dto.MediaSources) != 1 {
t.Fatalf("MediaSources = %v, want single-element slice", dto.MediaSources)
}
src := dto.MediaSources[0]
if !src.SupportsDirectPlay || !src.SupportsDirectStream || !src.SupportsTranscoding || !src.SupportsProbing {
t.Errorf("stub source must advertise playability; got %+v", src)
}
if src.ID != "0" {
t.Errorf("stub source ID = %q, want \"0\" (never a registered media source owner)", src.ID)
}
if src.Name != "Pilot" || src.RunTimeTicks != 34800000000 {
t.Errorf("stub source should mirror item Name/RunTimeTicks; got Name=%q ticks=%d", src.Name, src.RunTimeTicks)
}
if len(src.MediaStreams) != 1 {
t.Errorf("stub source should nest a stream stub; got %v", src.MediaStreams)
}
// Null arrays (vs empty) break strict client deserializers — real servers
// always serialize these as []/{}.
if src.Formats == nil || src.RequiredHTTPHeaders == nil || src.MediaAttachments == nil {
t.Errorf("stub source must not serialize null collections; got Formats=%v RequiredHTTPHeaders=%v MediaAttachments=%v",
src.Formats, src.RequiredHTTPHeaders, src.MediaAttachments)
}
}
// TestStubDetailListFields_FreshMediaSourcePerCall guards against reference
// sharing across responses: the stub is built by copying the package-level
// stubDetailMediaSource value, which only stays safe while that var's
// reference-typed fields (slices/maps) remain nil. Mirrors the existing
// chapter-map guard.
func TestStubDetailListFields_FreshMediaSourcePerCall(t *testing.T) {
a := baseItemDTO{}
b := baseItemDTO{}
fields := map[string]bool{"mediasources": true}
stubDetailListFields(&a, fields)
stubDetailListFields(&b, fields)
a.MediaSources[0].RequiredHTTPHeaders["X-Mutated"] = "by-a"
a.MediaSources[0].MediaStreams[0].Index = 99
if len(b.MediaSources[0].RequiredHTTPHeaders) != 0 {
t.Error("mutating dto a's RequiredHTTPHeaders leaked into dto b")
}
if b.MediaSources[0].MediaStreams[0].Index == 99 {
t.Error("mutating dto a's nested stream leaked into dto b")
}
}
+165
View File
@@ -0,0 +1,165 @@
package jellycompat
import (
"context"
"errors"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type stubSettingsReader struct {
values map[string]string
err error
}
func (s stubSettingsReader) Get(_ context.Context, key string) (string, error) {
if s.err != nil {
return "", s.err
}
return s.values[key], nil
}
func TestAllow4KVideoTranscode(t *testing.T) {
tests := []struct {
name string
repo SettingsReader
want bool
}{
{name: "nil repo defaults to deny", repo: nil, want: false},
{name: "unset defaults to deny", repo: stubSettingsReader{}, want: false},
{name: "read error defaults to deny", repo: stubSettingsReader{err: errors.New("read failed")}, want: false},
{name: "explicit false denies", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "false"}}, want: false},
{name: "explicit true allows", repo: stubSettingsReader{values: map[string]string{"allow_4k_transcode": "true"}}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &PlaybackHandler{SettingsRepo: tt.repo}
if got := h.allow4KVideoTranscode(context.Background()); got != tt.want {
t.Errorf("allow4KVideoTranscode() = %v, want %v", got, tt.want)
}
})
}
}
func TestIs4KResolution(t *testing.T) {
for res, want := range map[string]bool{
"2160p": true,
"4320p": true,
"1080p": false,
"720p": false,
"": false,
} {
if got := is4KResolution(res); got != want {
t.Errorf("is4KResolution(%q) = %v, want %v", res, got, want)
}
}
}
func TestBuildPlaybackSource4KVideoTranscodeGate(t *testing.T) {
version4K := catalog.FileVersion{
FileID: 1,
Resolution: "2160p",
CodecVideo: "hevc",
CodecAudio: "eac3",
Container: "mkv",
}
version1080 := version4K
version1080.Resolution = "1080p"
// Can only decode h264: HEVC versions need a full video transcode.
h264Only := DeviceProfile{
DirectPlayProfiles: []DirectPlayProfile{
{Type: "Video", Container: "mp4", VideoCodec: "h264", AudioCodec: "aac"},
},
}
// Decodes HEVC but not EAC3: transcoding path stream-copies the video.
hevcNoEac3 := DeviceProfile{
DirectPlayProfiles: []DirectPlayProfile{
{Type: "Video", Container: "mp4", VideoCodec: "h264,hevc", AudioCodec: "aac"},
},
}
tests := []struct {
name string
version catalog.FileVersion
profile DeviceProfile
allow4K bool
wantTranscoding bool
wantTranscodeAudio bool
}{
{
name: "4K video transcode blocked when disallowed",
version: version4K,
profile: h264Only,
allow4K: false,
wantTranscoding: false,
},
{
name: "4K video transcode offered when allowed",
version: version4K,
profile: h264Only,
allow4K: true,
wantTranscoding: true,
},
{
name: "4K audio-only transcode (video copy) stays allowed",
version: version4K,
profile: hevcNoEac3,
allow4K: false,
wantTranscoding: true,
wantTranscodeAudio: true,
},
{
name: "non-4K video transcode unaffected",
version: version1080,
profile: h264Only,
allow4K: false,
wantTranscoding: true,
},
}
h := &PlaybackHandler{codec: NewResourceIDCodec()}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := h.buildPlaybackSource("item", "ps", tt.version, tt.profile, playbackInfoRequest{}, tt.allow4K)
if source.SupportsTranscoding != tt.wantTranscoding {
t.Errorf("SupportsTranscoding = %v, want %v", source.SupportsTranscoding, tt.wantTranscoding)
}
if source.TranscodeAudio != tt.wantTranscodeAudio {
t.Errorf("TranscodeAudio = %v, want %v", source.TranscodeAudio, tt.wantTranscodeAudio)
}
})
}
}
func TestEnsureTranscodeSession4KGuard(t *testing.T) {
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
source := PlaybackMediaSource{
FileID: 1,
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
}
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); !errors.Is(err, errTranscode4KDisallowed) {
t.Errorf("ensureTranscodeSession() error = %v, want errTranscode4KDisallowed", err)
}
// Video-copy sessions pass the guard (and fail later on the missing file
// resolver, which is fine for this test).
source.TranscodeAudio = true
if _, err := h.ensureTranscodeSession(context.Background(), "ps", "session", source); errors.Is(err, errTranscode4KDisallowed) {
t.Error("ensureTranscodeSession() blocked a video-copy session")
}
}
func TestStartRemoteTranscode4KGuard(t *testing.T) {
h := &PlaybackHandler{} // nil SettingsRepo: 4K video transcodes denied
source := PlaybackMediaSource{
FileID: 1,
Version: catalog.FileVersion{FileID: 1, Resolution: "2160p", CodecVideo: "hevc"},
}
if err := h.startRemoteTranscode(context.Background(), "session", source, nil, 0, "http://node"); !errors.Is(err, errTranscode4KDisallowed) {
t.Errorf("startRemoteTranscode() error = %v, want errTranscode4KDisallowed", err)
}
}
@@ -0,0 +1,111 @@
package jellycompat
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func ep(id string) *models.Episode {
return &models.Episode{ContentID: id}
}
// TestSeriesUserDataFromEpisodes covers the series watch-state rollup: a
// series has no progress row of its own, so Played/UnplayedCount/
// InProgressCount are aggregated from per-episode progress.
func TestSeriesUserDataFromEpisodes(t *testing.T) {
cases := []struct {
name string
episodes []*models.Episode
progress map[string]userstore.WatchProgress
wantWatched int
wantUnplayed int
wantInProgress int
wantPlayed bool
}{
{
name: "no episodes",
episodes: nil,
progress: map[string]userstore.WatchProgress{},
// Played must be false for an empty series — unplayed==0 alone
// must not flip it.
wantPlayed: false,
},
{
name: "all watched",
episodes: []*models.Episode{ep("a"), ep("b")},
progress: map[string]userstore.WatchProgress{
"a": {Completed: true},
"b": {Completed: true},
},
wantWatched: 2,
wantPlayed: true,
},
{
name: "mixed watched, in-progress, untouched",
episodes: []*models.Episode{ep("a"), ep("b"), ep("c")},
progress: map[string]userstore.WatchProgress{
"a": {Completed: true},
"b": {PositionSeconds: 120},
},
wantWatched: 1,
wantUnplayed: 2,
wantInProgress: 1,
wantPlayed: false,
},
{
name: "no progress at all",
episodes: []*models.Episode{ep("a")},
progress: map[string]userstore.WatchProgress{},
wantUnplayed: 1,
wantPlayed: false,
},
{
name: "nil episodes skipped",
episodes: []*models.Episode{nil, ep("a"), nil},
progress: map[string]userstore.WatchProgress{
"a": {Completed: true},
},
wantWatched: 1,
wantPlayed: true,
},
{
name: "zero-position progress row is not in-progress",
episodes: []*models.Episode{ep("a")},
progress: map[string]userstore.WatchProgress{
"a": {PositionSeconds: 0},
},
wantUnplayed: 1,
wantInProgress: 0,
wantPlayed: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := seriesUserDataFromEpisodes(tc.episodes, tc.progress)
if got.WatchedCount != tc.wantWatched {
t.Errorf("WatchedCount = %d, want %d", got.WatchedCount, tc.wantWatched)
}
if got.UnplayedCount != tc.wantUnplayed {
t.Errorf("UnplayedCount = %d, want %d", got.UnplayedCount, tc.wantUnplayed)
}
if got.InProgressCount != tc.wantInProgress {
t.Errorf("InProgressCount = %d, want %d", got.InProgressCount, tc.wantInProgress)
}
if got.Played != tc.wantPlayed {
t.Errorf("Played = %v, want %v", got.Played, tc.wantPlayed)
}
})
}
}
// TestModelEpisodeContentIDs verifies nil episodes and empty content ids are
// dropped — these ids feed SQL IN-lists.
func TestModelEpisodeContentIDs(t *testing.T) {
got := modelEpisodeContentIDs([]*models.Episode{nil, ep(""), ep("a"), ep("b")})
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Errorf("modelEpisodeContentIDs = %v, want [a b]", got)
}
}
@@ -0,0 +1,51 @@
package jellycompat
import "testing"
// TestSlicePageIDs locks slicePageIDs to sliceBaseItems' exact bounds
// semantics — the two slice the same page in parallel in
// writeNextUpResponse, and any divergence would attach detail upgrades to
// the wrong DTOs.
func TestSlicePageIDs(t *testing.T) {
ids := []string{"a", "b", "c", "d"}
cases := []struct {
name string
startIndex int
limit int
want []string
}{
{"plain page", 0, 2, []string{"a", "b"}},
{"offset page", 1, 2, []string{"b", "c"}},
{"limit past end clamps", 2, 10, []string{"c", "d"}},
{"start past end is empty", 9, 2, []string{}},
{"zero limit returns rest", 1, 0, []string{"b", "c", "d"}},
{"negative start clamps to zero", -3, 2, []string{"a", "b"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := slicePageIDs(ids, tc.startIndex, tc.limit)
if len(got) != len(tc.want) {
t.Fatalf("len = %d, want %d (%v)", len(got), len(tc.want), got)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
// Mirror against sliceBaseItems on a same-length DTO slice.
dtos := make([]baseItemDTO, len(ids))
for i := range ids {
dtos[i] = baseItemDTO{ID: ids[i]}
}
sliced := sliceBaseItems(dtos, tc.startIndex, tc.limit)
if len(sliced) != len(got) {
t.Fatalf("sliceBaseItems len %d diverges from slicePageIDs len %d", len(sliced), len(got))
}
for i := range sliced {
if sliced[i].ID != got[i] {
t.Errorf("alignment broken at %d: dto %q vs id %q", i, sliced[i].ID, got[i])
}
}
})
}
}
+17 -1
View File
@@ -165,6 +165,10 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
return
}
if err := h.startRemoteTranscode(r.Context(), playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
return
}
writeError(w, http.StatusBadGateway, "TranscodeStartFailed", "Transcode node rejected the request")
return
}
@@ -182,6 +186,10 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
// Ensure the transcode process is running.
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
if err != nil {
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
return
}
if errors.Is(err, playback.ErrManifestNotReady) {
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
return
@@ -228,6 +236,10 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
// Ensure the transcode process is running.
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
if err != nil {
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
return
}
if errors.Is(err, playback.ErrManifestNotReady) {
writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready")
return
@@ -810,6 +822,9 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
if existing := h.getTranscodeSession(upstreamSessionID); existing != nil {
return existing, nil
}
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
return nil, errTranscode4KDisallowed
}
if h.fileResolver == nil {
return nil, fmt.Errorf("file resolver not available")
}
@@ -1019,8 +1034,9 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
playSessionID := h.codec.EncodeStringID(EncodedIDPlaySession, uuidNewString())
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
allow4KTranscode := h.allow4KVideoTranscode(ctx)
for _, version := range detail.Versions {
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{})
source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{}, allow4KTranscode)
sources = append(sources, source)
}
+206 -19
View File
@@ -1,15 +1,38 @@
package playback
import (
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const defaultDRIDir = "/dev/dri"
var (
defaultDRIDir = "/dev/dri"
defaultNVIDIAControlDevice = "/dev/nvidiactl"
defaultNVIDIADeviceGlob = "/dev/nvidia[0-9]*"
sysClassDRMDir = "/sys/class/drm"
currentGOOS = runtime.GOOS
nvencProbeCommandTimeout = 3 * time.Second
)
type nvencProbeResult struct {
available bool
reason string
}
var nvencProbeCache = struct {
sync.Mutex
byPath map[string]nvencProbeResult
}{
byPath: make(map[string]nvencProbeResult),
}
// HWAccelInfo describes the detected hardware acceleration capability.
type HWAccelInfo struct {
@@ -22,6 +45,11 @@ type HWAccelInfo struct {
// DetectHWAccel probes this host's GPU hardware and returns structured info.
func DetectHWAccel() HWAccelInfo {
return DetectHWAccelWithFFmpeg("")
}
// DetectHWAccelWithFFmpeg probes this host's GPU hardware and configured FFmpeg.
func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
devices := listRenderDevices(defaultDRIDir)
intel := false
for _, d := range devices {
@@ -31,7 +59,7 @@ func DetectHWAccel() HWAccelInfo {
}
}
return HWAccelInfo{
Resolved: ResolveHWAccel("auto"),
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
RenderDevices: devices,
IntelDetected: intel,
Source: "local",
@@ -53,34 +81,163 @@ func PickRenderDevice(explicit string) string {
return dev
}
// ResolveHWAccel resolves "auto" into a concrete acceleration method by
// probing the system. Preference order: qsv > vaapi > none.
// Non-"auto" values are returned unchanged.
// ResolveHWAccel resolves "auto" using the default FFmpeg binary.
func ResolveHWAccel(hwAccel string) string {
return ResolveHWAccelWithFFmpeg(hwAccel, "")
}
// ResolveHWAccelWithFFmpeg resolves "auto" into a concrete acceleration method
// by probing the system and the configured FFmpeg binary.
// Preference order: nvenc > qsv > vaapi > none.
// Non-"auto" values are returned unchanged.
func ResolveHWAccelWithFFmpeg(hwAccel string, ffmpegPath string) string {
if hwAccel != "auto" {
return hwAccel
}
if runtime.GOOS != "linux" {
if currentGOOS != "linux" {
return "none"
}
devices := listRenderDevices(defaultDRIDir)
if len(devices) == 0 {
slog.Info("hw_accel=auto: no render devices found, using software encoding")
return "none"
}
// Check for Intel GPU — enables QSV (preferred).
var intelDevice string
var nvidiaDevice string
var vaapiDevice string
for _, dev := range devices {
if isIntelDevice(dev) {
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", dev)
return "qsv"
switch {
case isNVIDIADevice(dev):
if nvidiaDevice == "" {
nvidiaDevice = dev
}
case isIntelDevice(dev):
if intelDevice == "" {
intelDevice = dev
}
default:
if vaapiDevice == "" {
vaapiDevice = dev
}
}
}
// Any accessible render device supports VAAPI.
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", devices[0])
return "vaapi"
if nvidiaDevice != "" || hasNVIDIADevice() {
if ok, reason := ffmpegSupportsNVENC(ffmpegPath); ok {
if nvidiaDevice != "" {
slog.Info("hw_accel=auto: NVIDIA GPU detected, using NVENC", "device", nvidiaDevice)
} else {
slog.Info("hw_accel=auto: NVIDIA device detected, using NVENC")
}
return "nvenc"
} else {
slog.Warn("hw_accel=auto: NVIDIA device detected but FFmpeg NVENC probe failed",
"ffmpeg", normalizeFFmpegPath(ffmpegPath), "reason", reason)
}
}
if intelDevice != "" {
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", intelDevice)
return "qsv"
}
if vaapiDevice != "" {
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", vaapiDevice)
return "vaapi"
}
slog.Info("hw_accel=auto: no compatible GPU devices found, using software encoding")
return "none"
}
func ffmpegSupportsNVENC(ffmpegPath string) (bool, string) {
ffmpegPath = normalizeFFmpegPath(ffmpegPath)
nvencProbeCache.Lock()
if result, ok := nvencProbeCache.byPath[ffmpegPath]; ok {
nvencProbeCache.Unlock()
return result.available, result.reason
}
nvencProbeCache.Unlock()
result := probeFFmpegNVENC(ffmpegPath)
nvencProbeCache.Lock()
nvencProbeCache.byPath[ffmpegPath] = result
nvencProbeCache.Unlock()
return result.available, result.reason
}
func normalizeFFmpegPath(ffmpegPath string) string {
ffmpegPath = strings.TrimSpace(ffmpegPath)
if ffmpegPath == "" {
ffmpegPath = ffmpegBinary()
}
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
return filepath.Clean(ffmpegPath)
}
return ffmpegPath
}
func probeFFmpegNVENC(ffmpegPath string) nvencProbeResult {
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-hwaccels"); err != nil {
return nvencProbeResult{reason: "hwaccels probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "cuda") {
return nvencProbeResult{reason: "cuda hwaccel unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-encoders"); err != nil {
return nvencProbeResult{reason: "encoders probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "h264_nvenc") {
return nvencProbeResult{reason: "h264_nvenc encoder unavailable"}
} else if !ffmpegOutputHasToken(output, "hevc_nvenc") {
return nvencProbeResult{reason: "hevc_nvenc encoder unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-filters"); err != nil {
return nvencProbeResult{reason: "filters probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "scale_cuda") {
return nvencProbeResult{reason: "scale_cuda filter unavailable"}
} else if !ffmpegOutputHasToken(output, "hwupload_cuda") {
return nvencProbeResult{reason: "hwupload_cuda filter unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-f", "lavfi",
"-i", "testsrc2=size=64x64:rate=1",
"-frames:v", "1",
"-an",
"-c:v", "h264_nvenc",
"-f", "null",
"-",
); err != nil {
return nvencProbeResult{reason: "h264_nvenc smoke encode failed: " + probeFailure(err, output)}
}
return nvencProbeResult{available: true}
}
func runFFmpegProbe(ffmpegPath string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nvencProbeCommandTimeout)
defer cancel()
return exec.CommandContext(ctx, ffmpegPath, args...).CombinedOutput()
}
func ffmpegOutputHasToken(output []byte, token string) bool {
for _, field := range strings.Fields(string(output)) {
if strings.EqualFold(field, token) {
return true
}
}
return false
}
func probeFailure(err error, output []byte) string {
message := strings.TrimSpace(err.Error())
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
if len(trimmed) > 240 {
trimmed = trimmed[:240] + "..."
}
message += ": " + trimmed
}
return message
}
// listRenderDevices returns all accessible /dev/dri/renderD* paths, sorted.
@@ -107,7 +264,7 @@ func listRenderDevices(driDir string) []string {
func isIntelDevice(renderDevPath string) bool {
// /dev/dri/renderD128 → card name "renderD128"
name := filepath.Base(renderDevPath)
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
data, err := os.ReadFile(vendorPath)
if err != nil {
return false
@@ -115,6 +272,36 @@ func isIntelDevice(renderDevPath string) bool {
return strings.TrimSpace(string(data)) == "0x8086"
}
// isNVIDIADevice checks whether a render device belongs to an NVIDIA GPU by
// reading the PCI vendor ID from sysfs. NVIDIA vendor ID is 0x10de.
func isNVIDIADevice(renderDevPath string) bool {
name := filepath.Base(renderDevPath)
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
data, err := os.ReadFile(vendorPath)
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "0x10de"
}
func hasNVIDIADevice() bool {
if file, err := os.Open(defaultNVIDIAControlDevice); err == nil {
file.Close()
return true
}
matches, err := filepath.Glob(defaultNVIDIADeviceGlob)
if err != nil || len(matches) == 0 {
return false
}
for _, dev := range matches {
if file, err := os.Open(dev); err == nil {
file.Close()
return true
}
}
return false
}
// detectRenderDevice enumerates /dev/dri/renderD* and returns the first
// available device, or empty string if none found.
func detectRenderDevice(driDir string) string {
+304
View File
@@ -0,0 +1,304 @@
package playback
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type hwAccelTestEnv struct {
devDir string
driDir string
sysDir string
}
type fakeFFmpegProbe struct {
cuda bool
h264NVENC bool
hevcNVENC bool
scaleCUDA bool
uploadCUDA bool
smokeOK bool
hang bool
}
type fakeFFmpegBinary struct {
path string
logPath string
}
func TestResolveHWAccelWithFFmpegAutoPrefersNVENCOverIntel(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x8086")
env.addRenderDevice(t, "renderD129", "0x10de")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestResolveHWAccelWithFFmpegFallsBackToIntelWhenNVENCProbeFails(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x8086")
env.addRenderDevice(t, "renderD129", "0x10de")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "qsv" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want qsv", got)
}
}
func TestResolveHWAccelWithFFmpegFallsBackToVAAPIWhenNVENCProbeFails(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
env.addRenderDevice(t, "renderD129", "0x1002")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "vaapi" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want vaapi", got)
}
}
func TestResolveHWAccelWithFFmpegReturnsNoneWhenNVENCProbeFailsWithoutFallback(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "none" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want none", got)
}
}
func TestResolveHWAccelWithFFmpegUsesNVIDIADeviceNodesWithoutDRM(t *testing.T) {
env := setupHWAccelTest(t)
env.addNVIDIADevice(t, "nvidia0")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestExplicitNVENCBypassesFFmpegProbe(t *testing.T) {
setupHWAccelTest(t)
if got := ResolveHWAccelWithFFmpeg("nvenc", "/does/not/exist/ffmpeg"); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestFFmpegSupportsNVENCRequiresCUDAEncodersFiltersAndSmoke(t *testing.T) {
setupHWAccelTest(t)
tests := []struct {
name string
probe fakeFFmpegProbe
}{
{
name: "missing cuda hwaccel",
probe: fakeFFmpegProbe{
h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing h264 nvenc encoder",
probe: fakeFFmpegProbe{
cuda: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing hevc nvenc encoder",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing scale cuda filter",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing hwupload cuda filter",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, smokeOK: true,
},
},
{
name: "smoke encode failure",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true,
},
},
{
name: "probe timeout",
probe: fakeFFmpegProbe{hang: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetNVENCProbeCacheForTest()
ffmpeg := writeFakeFFmpeg(t, tt.probe)
if ok, reason := ffmpegSupportsNVENC(ffmpeg.path); ok {
t.Fatalf("ffmpegSupportsNVENC() = true, want false")
} else if reason == "" {
t.Fatalf("ffmpegSupportsNVENC() reason is empty")
}
})
}
}
func TestFFmpegSupportsNVENCCachesByFFmpegPath(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
for i := 0; i < 2; i++ {
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() call %d = %q, want nvenc", i+1, got)
}
}
logData, err := os.ReadFile(ffmpeg.logPath)
if err != nil {
t.Fatalf("read ffmpeg probe log: %v", err)
}
if got := strings.Count(string(logData), "\n"); got != 4 {
t.Fatalf("probe command count = %d, want 4; log:\n%s", got, logData)
}
}
func successfulNVENCProbe() fakeFFmpegProbe {
return fakeFFmpegProbe{
cuda: true,
h264NVENC: true,
hevcNVENC: true,
scaleCUDA: true,
uploadCUDA: true,
smokeOK: true,
}
}
func setupHWAccelTest(t *testing.T) *hwAccelTestEnv {
t.Helper()
oldDRIDir := defaultDRIDir
oldNVIDIAControlDevice := defaultNVIDIAControlDevice
oldNVIDIADeviceGlob := defaultNVIDIADeviceGlob
oldSysClassDRMDir := sysClassDRMDir
oldGOOS := currentGOOS
oldProbeTimeout := nvencProbeCommandTimeout
resetNVENCProbeCacheForTest()
tmp := t.TempDir()
env := &hwAccelTestEnv{
devDir: filepath.Join(tmp, "dev"),
driDir: filepath.Join(tmp, "dev", "dri"),
sysDir: filepath.Join(tmp, "sys", "class", "drm"),
}
defaultDRIDir = env.driDir
defaultNVIDIAControlDevice = filepath.Join(env.devDir, "nvidiactl")
defaultNVIDIADeviceGlob = filepath.Join(env.devDir, "nvidia[0-9]*")
sysClassDRMDir = env.sysDir
currentGOOS = "linux"
nvencProbeCommandTimeout = 200 * time.Millisecond
if err := os.MkdirAll(env.driDir, 0o755); err != nil {
t.Fatalf("create test dri dir: %v", err)
}
if err := os.MkdirAll(env.devDir, 0o755); err != nil {
t.Fatalf("create test dev dir: %v", err)
}
t.Cleanup(func() {
defaultDRIDir = oldDRIDir
defaultNVIDIAControlDevice = oldNVIDIAControlDevice
defaultNVIDIADeviceGlob = oldNVIDIADeviceGlob
sysClassDRMDir = oldSysClassDRMDir
currentGOOS = oldGOOS
nvencProbeCommandTimeout = oldProbeTimeout
resetNVENCProbeCacheForTest()
})
return env
}
func (e *hwAccelTestEnv) addRenderDevice(t *testing.T, name string, vendor string) {
t.Helper()
if err := os.WriteFile(filepath.Join(e.driDir, name), []byte{}, 0o600); err != nil {
t.Fatalf("create render device: %v", err)
}
vendorPath := filepath.Join(e.sysDir, name, "device", "vendor")
if err := os.MkdirAll(filepath.Dir(vendorPath), 0o755); err != nil {
t.Fatalf("create vendor dir: %v", err)
}
if err := os.WriteFile(vendorPath, []byte(vendor+"\n"), 0o644); err != nil {
t.Fatalf("write vendor file: %v", err)
}
}
func (e *hwAccelTestEnv) addNVIDIADevice(t *testing.T, name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(e.devDir, name), []byte{}, 0o600); err != nil {
t.Fatalf("create nvidia device: %v", err)
}
}
func writeFakeFFmpeg(t *testing.T, probe fakeFFmpegProbe) fakeFFmpegBinary {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "ffmpeg")
logPath := filepath.Join(dir, "probe.log")
script := "#!/bin/sh\n"
script += fmt.Sprintf("printf '%%s\\n' \"$*\" >> %q\n", logPath)
if probe.hang {
script += "sleep 5\n"
}
script += "case \"$*\" in\n"
script += " *-hwaccels*)\n"
script += " echo 'Hardware acceleration methods:'\n"
if probe.cuda {
script += " echo 'cuda'\n"
}
script += " exit 0 ;;\n"
script += " *-encoders*)\n"
if probe.h264NVENC {
script += " echo ' V..... h264_nvenc NVIDIA NVENC H.264 encoder'\n"
}
if probe.hevcNVENC {
script += " echo ' V..... hevc_nvenc NVIDIA NVENC hevc encoder'\n"
}
script += " exit 0 ;;\n"
script += " *-filters*)\n"
if probe.scaleCUDA {
script += " echo ' ... scale_cuda V->V GPU video scaling'\n"
}
if probe.uploadCUDA {
script += " echo ' ... hwupload_cuda V->V upload CUDA frames'\n"
}
script += " exit 0 ;;\n"
script += " *)\n"
if probe.smokeOK {
script += " exit 0 ;;\n"
} else {
script += " echo 'no capable devices found' >&2\n"
script += " exit 1 ;;\n"
}
script += "esac\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
return fakeFFmpegBinary{path: path, logPath: logPath}
}
func resetNVENCProbeCacheForTest() {
nvencProbeCache.Lock()
defer nvencProbeCache.Unlock()
nvencProbeCache.byPath = make(map[string]nvencProbeResult)
}
+59 -4
View File
@@ -39,7 +39,7 @@ type TranscodeOpts struct {
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, none
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
@@ -294,6 +294,9 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
} else if opts.HWAccel == "vaapi" {
scale := vaapiScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.HWAccel == "nvenc" {
scale := nvencScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.TargetResolution != "" {
scale := resolutionToScale(opts.TargetResolution)
if scale != "" {
@@ -351,7 +354,7 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
}
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
hwAccel := ResolveHWAccel(opts.HWAccel)
hwAccel := ResolveHWAccelWithFFmpeg(opts.HWAccel, opts.FFmpegPath)
if hwAccel == "" {
return ""
}
@@ -422,12 +425,12 @@ func appendSegmentBoundaryArgs(args []string, opts TranscodeOpts) []string {
fmt.Sprintf("expr:gte(t,n_forced*%d)", opts.SegmentDuration))
}
// Hardware encoders (QSV, VAAPI) may not reliably honor
// Hardware encoders (QSV, VAAPI, NVENC) may not reliably honor
// force_key_frames expressions. Set explicit GOP size so segment
// boundaries always start with an IDR frame. We assume 30 fps as a
// safe ceiling — the GOP will be at most segmentDuration * 30 frames.
// Matches Jellyfin's approach for hardware encoders.
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" {
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" || opts.HWAccel == "nvenc" {
gopSize := fmt.Sprintf("%d", opts.SegmentDuration*30)
args = append(args, "-g", gopSize, "-keyint_min", gopSize)
}
@@ -465,6 +468,15 @@ func appendHWAccelArgs(args []string, opts TranscodeOpts) []string {
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
)
case "nvenc":
args = append(args,
"-hwaccel", "cuda",
"-hwaccel_output_format", "cuda",
"-noautorotate",
)
if hwDevice := strings.TrimSpace(opts.HWDevice); hwDevice != "" {
args = append(args, "-hwaccel_device", hwDevice)
}
}
return args
}
@@ -530,6 +542,26 @@ func appendVideoArgs(args []string, opts TranscodeOpts) []string {
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
}
case opts.HWAccel == "nvenc" && codec == "h264":
args = append(args, "-c:v", "h264_nvenc", "-rc:v", "vbr")
if hasBitrateCap {
args = append(args,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-cq:v", "23", "-b:v", "0")
}
case opts.HWAccel == "nvenc" && codec == "hevc":
args = append(args, "-c:v", "hevc_nvenc", "-rc:v", "vbr")
if hasBitrateCap {
args = append(args,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-cq:v", "28", "-b:v", "0")
}
default:
// CPU fallback — match Jellyfin's proven browser-compatible settings.
// Force yuv420p to ensure 8-bit output (10-bit sources produce High 10
@@ -609,6 +641,10 @@ func appendSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
// VAAPI-only: download, apply CPU filters, convert to nv12, upload back.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload"
args = append(args, "-vf", vf)
case "nvenc":
// NVENC/CUDA: download to CPU for subtitle rendering, then upload back.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload_cuda"
args = append(args, "-vf", vf)
default:
// CPU encoding: filters run directly on decoded frames.
args = append(args, "-vf", cpuFilters)
@@ -679,6 +715,25 @@ func vaapiScaleFilter(res string) string {
}
}
func nvencScaleFilter(res string) string {
switch res {
case "2160p":
return "scale_cuda=w=-2:h=2160:format=nv12"
case "1080p":
return "scale_cuda=w=-2:h=1080:format=nv12"
case "720p":
return "scale_cuda=w=-2:h=720:format=nv12"
case "480p":
return "scale_cuda=w=-2:h=480:format=nv12"
case "420p":
return "scale_cuda=w=-2:h=420:format=nv12"
case "328p":
return "scale_cuda=w=-2:h=328:format=nv12"
default:
return "scale_cuda=format=nv12"
}
}
// filterPathReplacer escapes special characters in file paths for ffmpeg filter syntax.
var filterPathReplacer = strings.NewReplacer(
"'", "'\\''",
+39 -2
View File
@@ -113,7 +113,7 @@ func TestBuildFFmpegArgs_CopyVideoSeekPreservesCodecCopy(t *testing.T) {
t.Fatalf("copy-mode seek should preserve -c:v copy: %s", joined)
}
// Must not contain any video encoder.
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv"} {
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc"} {
if strings.Contains(joined, enc) {
t.Fatalf("copy-mode seek should not use encoder %s: %s", enc, joined)
}
@@ -166,7 +166,7 @@ func TestBuildFFmpegArgs_MPEG2CopyVideoUsesMPEGTS(t *testing.T) {
if strings.Contains(joined, "movflags=+frag_discont") {
t.Fatalf("mpeg2 MPEG-TS copy-mode should not use fMP4 movflags: %s", joined)
}
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv", "libx265"} {
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc", "libx265"} {
if strings.Contains(joined, enc) {
t.Fatalf("mpeg2 copy-mode should not use encoder %s: %s", enc, joined)
}
@@ -234,6 +234,11 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
want: "none",
},
{
name: "nvenc passthrough",
opts: TranscodeOpts{HWAccel: "nvenc", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
want: "nvenc",
},
}
for _, tt := range tests {
@@ -247,6 +252,38 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
}
}
func TestBuildFFmpegArgs_NVENCH264UsesCudaPipeline(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
SessionID: "session-nvenc",
SourceVideoCodec: "h264",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
HWAccel: "nvenc",
TargetResolution: "720p",
TargetBitrateKbps: 2000,
})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-hwaccel cuda") {
t.Fatalf("nvenc args should enable cuda hwaccel: %s", joined)
}
if !strings.Contains(joined, "-c:v h264_nvenc") {
t.Fatalf("nvenc args should use h264_nvenc encoder: %s", joined)
}
if !strings.Contains(joined, "-vf scale_cuda=w=-2:h=720:format=nv12") {
t.Fatalf("nvenc args should use scale_cuda, not software scale: %s", joined)
}
if strings.Contains(joined, "-vf scale=-2:720") {
t.Fatalf("nvenc args must not use software scale on cuda frames: %s", joined)
}
if !strings.Contains(joined, "-b:v 2000k -maxrate 2000k -bufsize 4000k") {
t.Fatalf("nvenc args should include bitrate cap controls: %s", joined)
}
}
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
+47
View File
@@ -482,6 +482,53 @@ func (s *Service) ScanSourceClient(
return client.ScanSource(capabilityID)
}
func (s *Service) ScanSourceClientByPluginID(
ctx context.Context,
pluginID string,
capabilityID string,
) (*pluginhost.ScanSourceClient, error) {
if s == nil || s.installations == nil {
return nil, fmt.Errorf("scan source plugin resolver is not configured")
}
installations, err := s.installations.ListByPluginID(ctx, pluginID)
if err != nil {
return nil, err
}
if len(installations) == 0 {
return nil, fmt.Errorf("scan source plugin %q is not installed", pluginID)
}
if len(installations) > 1 {
return nil, fmt.Errorf("scan source plugin %q is ambiguous across %d installations", pluginID, len(installations))
}
var matches []*Installation
for _, installation := range installations {
if installation == nil {
continue
}
capabilities, err := s.installations.ListCapabilities(ctx, installation.ID)
if err != nil {
return nil, err
}
for _, capability := range capabilities {
if capability == nil {
continue
}
if capability.Type == "scan_source.v1" && capability.ID == capabilityID {
matches = append(matches, installation)
break
}
}
}
if len(matches) == 0 {
return nil, fmt.Errorf("scan source capability %q is not installed for plugin %q", capabilityID, pluginID)
}
if !matches[0].Enabled {
return nil, fmt.Errorf("scan source plugin %q is disabled", pluginID)
}
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
}
func (s *Service) EventConsumerClient(
ctx context.Context,
installationID int,
@@ -0,0 +1,78 @@
package plugins
import (
"context"
"strings"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/pluginhost"
)
func TestScanSourceClientByPluginIDResolvesCurrentInstallation(t *testing.T) {
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
}
installPath := writeInstalledPluginManifest(t, manifest)
host := &fakeServiceHost{
clientErr: pluginhost.ErrClientNotFound,
startResult: &fakePluginClient{manifest: manifest},
}
service := &Service{
installations: &fakeServiceInstallationStore{
byID: map[int]*Installation{
8: {
ID: 8,
PluginID: "silo.autoscan.cephfs",
Version: "0.2.2",
InstallPath: installPath,
Enabled: true,
},
},
byPluginID: map[string][]*Installation{
"silo.autoscan.cephfs": {
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
},
},
listCapabilities: []*Capability{{InstallationID: 8, Type: "scan_source.v1", ID: "cephfs"}},
},
host: host,
}
if _, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs"); err != nil {
t.Fatalf("ScanSourceClientByPluginID: %v", err)
}
if len(host.started) != 1 || host.started[0].InstallationID != 8 {
t.Fatalf("started installations = %+v, want installation 8", host.started)
}
}
func TestScanSourceClientByPluginIDRejectsAmbiguousInstallations(t *testing.T) {
manifest := testPluginManifest(t, "silo.autoscan.cephfs", "0.2.2")
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
{Type: "scan_source.v1", Id: "cephfs", DisplayName: "CephFS"},
}
installPath := writeInstalledPluginManifest(t, manifest)
service := &Service{
installations: &fakeServiceInstallationStore{
byID: map[int]*Installation{
8: {ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
9: {ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
},
byPluginID: map[string][]*Installation{
"silo.autoscan.cephfs": {
{ID: 8, PluginID: "silo.autoscan.cephfs", Version: "0.2.2", InstallPath: installPath, Enabled: true},
{ID: 9, PluginID: "silo.autoscan.cephfs", Version: "0.2.3", InstallPath: installPath, Enabled: true},
},
},
listCapabilities: []*Capability{{Type: "scan_source.v1", ID: "cephfs"}},
},
host: &fakeServiceHost{},
}
_, err := service.ScanSourceClientByPluginID(context.Background(), "silo.autoscan.cephfs", "cephfs")
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ScanSourceClientByPluginID error = %v, want ambiguous", err)
}
}
+34 -27
View File
@@ -39,6 +39,35 @@ func ensureCanonicalDimensions(vec []float32) ([]float32, error) {
const embeddingLockSettingKey = "recommendations.embedding_lock"
const minHNSWEfSearch = 200
const tasteSeedCandidateQuery = `
WITH watched_counts AS (
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
FROM user_watch_progress wp
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
AND wp.updated_at > NOW() - INTERVAL '180 days'
GROUP BY 1
)
SELECT mi.content_id
FROM media_items mi
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
WHERE mi.status = 'matched'
AND mi.type IN ('movie', 'series')
AND mi.poster_path IS NOT NULL
AND mi.poster_path <> ''
ORDER BY COALESCE(wc.watch_count, 0) DESC,
CASE
WHEN mi.rating_imdb IS NOT NULL THEN 2
WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1
ELSE 0
END DESC,
mi.rating_imdb DESC NULLS LAST,
CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST,
mi.year DESC NULLS LAST,
mi.content_id ASC
LIMIT $1 OFFSET $2`
// Repo provides database operations for the recommendation system.
type Repo struct {
pool *pgxpool.Pool
@@ -1298,34 +1327,12 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
// taste-seeding picker: server engagement first (most-watched in the last
// 180 days), then TMDB rating, then recency. This blend ensures fresh servers
// (no watch history) still surface meaningful posters via TMDB ratings while
// established servers prioritize what users actually watch. Episodes are
// resolved to their parent series. Items without a poster are excluded.
// 180 days), then rating reliability and rating score, then recency. This keeps
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
// established servers prioritize what users actually watch. Episodes are resolved
// to their parent series. Items without a poster are excluded.
func (r *Repo) GetTasteSeedCandidates(ctx context.Context, limit, offset int) ([]string, error) {
rows, err := r.pool.Query(ctx, `
WITH watched_counts AS (
SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id,
COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count
FROM user_watch_progress wp
LEFT JOIN episodes e ON e.content_id = wp.media_item_id
WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5))
AND wp.updated_at > NOW() - INTERVAL '180 days'
GROUP BY 1
)
SELECT mi.content_id
FROM media_items mi
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
WHERE mi.status = 'matched'
AND mi.type IN ('movie', 'series')
AND mi.poster_path IS NOT NULL
AND mi.poster_path <> ''
ORDER BY COALESCE(wc.watch_count, 0) DESC,
mi.rating_tmdb DESC NULLS LAST,
mi.year DESC NULLS LAST,
mi.content_id ASC
LIMIT $1 OFFSET $2`,
limit, offset)
rows, err := r.pool.Query(ctx, tasteSeedCandidateQuery, limit, offset)
if err != nil {
return nil, fmt.Errorf("get taste seed candidates: %w", err)
}
+38
View File
@@ -0,0 +1,38 @@
package recommendations
import (
"strings"
"testing"
)
func TestTasteSeedCandidateQueryOrdersByReliableColdStartSignals(t *testing.T) {
query := strings.Join(strings.Fields(tasteSeedCandidateQuery), " ")
assertQueryTermsInOrder(t, query,
"ORDER BY COALESCE(wc.watch_count, 0) DESC",
"WHEN mi.rating_imdb IS NOT NULL THEN 2",
"WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1",
"ELSE 0 END DESC",
"mi.rating_imdb DESC NULLS LAST",
"CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST",
"mi.year DESC NULLS LAST",
"mi.content_id ASC",
)
if strings.Contains(query, "ORDER BY COALESCE(wc.watch_count, 0) DESC, mi.rating_tmdb DESC NULLS LAST") {
t.Fatal("taste seed query must not rank cold-start candidates by raw TMDB rating directly")
}
}
func assertQueryTermsInOrder(t *testing.T, query string, terms ...string) {
t.Helper()
searchFrom := 0
for _, term := range terms {
idx := strings.Index(query[searchFrom:], term)
if idx < 0 {
t.Fatalf("query term %q missing or out of order in query:\n%s", term, query)
}
searchFrom += idx + len(term)
}
}
+8 -1
View File
@@ -652,7 +652,8 @@ func (s *Scanner) upsertAudiobookMediaFiles(
folderPath string,
book *parsedAudiobook,
) error {
for _, af := range book.Files {
partTotal := len(book.Files)
for idx, af := range book.Files {
chapters := make([]models.MediaChapter, len(af.Chapters))
for i, ch := range af.Chapters {
chapters[i] = models.MediaChapter{
@@ -684,6 +685,12 @@ func (s *Scanner) upsertAudiobookMediaFiles(
Container: af.Container,
AudioChannels: af.AudioChannels,
}
if partTotal > 1 {
mf.PresentationKind = "multipart"
mf.PresentationGroupKey = contentID
mf.PresentationPartIndex = idx + 1
mf.PresentationPartTotal = partTotal
}
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
return fmt.Errorf("upsert media file %s: %w", af.Path, err)
+31
View File
@@ -225,6 +225,9 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
if err := s.ScanAudiobookFolder(watchCtx, folder); err != nil {
return nil, err
}
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
@@ -232,6 +235,9 @@ func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
return nil, err
}
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
@@ -1357,6 +1363,31 @@ func (s *Scanner) syncPresentLibraryState(ctx context.Context, folderID int) err
return nil
}
func (s *Scanner) syncFolderScopedAudioLibraryState(ctx context.Context, folderID int) error {
if err := s.syncPresentLibraryState(ctx, folderID); err != nil {
return err
}
if _, err := s.fileRepo.Pool().Exec(ctx, `
INSERT INTO media_item_roots (media_folder_id, canonical_root_path, content_id)
SELECT DISTINCT mf.media_folder_id, mf.canonical_root_path, mf.content_id
FROM media_files mf
JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND mf.missing_since IS NULL
AND mf.content_id IS NOT NULL
AND COALESCE(mf.canonical_root_path, '') <> ''
AND mi.type IN ('audiobook', 'podcast')
ON CONFLICT (media_folder_id, canonical_root_path)
DO UPDATE SET content_id = EXCLUDED.content_id,
last_seen_at = NOW()
`, folderID); err != nil {
return fmt.Errorf("restoring folder-scoped audio roots: %w", err)
}
return nil
}
func (s *Scanner) reconcileLibraryMemberships(ctx context.Context, folderID int) (int, int, []string, error) {
if s.episodeLibraryRepo != nil {
if _, err := s.episodeLibraryRepo.ReconcileFolderMembership(ctx, folderID); err != nil {
+8
View File
@@ -167,6 +167,14 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
{ID: "default-random-tv", Scope: "library", LibraryID: libraryID, Position: 5, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true},
}
case "audiobooks", "audiobook":
return []*PageSection{
{ID: "default-continue-listening", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Listening", ItemLimit: 20, Config: emptyCfg, Enabled: true},
{ID: "default-recently-added-audiobooks", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
{ID: "default-recently-released-audiobooks", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
{ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true},
{ID: "default-random-audiobooks", Scope: "library", LibraryID: libraryID, Position: 4, SectionType: SectionRandom, Title: "Random Picks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true},
}
default:
return DefaultLibrarySections(libraryID)
}
+62
View File
@@ -213,6 +213,68 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) {
})
}
func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
libraryID := 10
got := DefaultLibrarySectionsForType(&libraryID, "audiobooks")
if len(got) != 5 {
t.Fatalf("expected 5 audiobook default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-listening", sectionType: SectionContinueWatching, title: "Continue Listening", position: 0},
{index: 1, id: "default-recently-added-audiobooks", sectionType: SectionRecentlyAdded, title: "Recently Added Audiobooks", position: 1},
{index: 2, id: "default-recently-released-audiobooks", sectionType: SectionRecentlyReleased, title: "Recently Released Audiobooks", position: 2},
{index: 3, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 3},
{index: 4, id: "default-random-audiobooks", sectionType: SectionRandom, title: "Random Picks", position: 4},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
if section.Featured {
t.Fatalf("section %d featured = true, want false", tt.index)
}
}
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertEmptyJSON(t, got[3].Config)
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
libraryID := 99
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
+5 -1
View File
@@ -112,7 +112,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) handleHWCapabilities(w http.ResponseWriter, _ *http.Request) {
info := playback.DetectHWAccel()
ffmpegPath := ""
if cfg := s.watcher.Config(); cfg != nil {
ffmpegPath = cfg.Playback.FFmpegPath
}
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
INSERT INTO server_settings (key, value)
VALUES (
'audiobookshelf_compat.enabled',
COALESCE(
(SELECT value FROM server_settings WHERE key = 'audiobooks.enabled'),
'false'
)
)
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM server_settings WHERE key = 'audiobookshelf_compat.enabled';
-- +goose StatementEnd
@@ -0,0 +1,6 @@
-- +goose Up
DELETE FROM server_settings
WHERE key IN ('audiobookshelf_compat.listen', 'audiobookshelf_compat.public_url');
-- +goose Down
SELECT 1;
@@ -0,0 +1,145 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.autoscan_sources
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
ALTER TABLE public.autoscan_events
ADD COLUMN plugin_id text NOT NULL DEFAULT '';
UPDATE public.autoscan_sources s
SET plugin_id = pi.plugin_id
FROM public.plugin_installations pi
WHERE s.installation_id = pi.id
AND s.plugin_id = '';
WITH inferred AS (
SELECT s.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
FROM public.autoscan_sources s
JOIN public.plugin_capabilities pc
ON pc.capability_type = 'scan_source.v1'
AND pc.capability_id = s.capability_id
JOIN public.plugin_installations pi
ON pi.id = pc.plugin_installation_id
WHERE s.plugin_id = ''
GROUP BY s.id
)
UPDATE public.autoscan_sources s
SET plugin_id = inferred.plugin_id
FROM inferred
WHERE s.id = inferred.id
AND inferred.matches = 1;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.autoscan_sources
WHERE enabled = true
AND btrim(plugin_id) = ''
) THEN
RAISE EXCEPTION 'enabled autoscan source could not be migrated to plugin_id';
END IF;
END $$;
UPDATE public.autoscan_events e
SET plugin_id = s.plugin_id
FROM public.autoscan_sources s
WHERE e.source_id = s.id
AND e.plugin_id = ''
AND btrim(s.plugin_id) <> '';
UPDATE public.autoscan_events e
SET plugin_id = pi.plugin_id
FROM public.plugin_installations pi
WHERE e.installation_id = pi.id
AND e.plugin_id = '';
WITH inferred AS (
SELECT e.id, min(pi.plugin_id) AS plugin_id, count(*) AS matches
FROM public.autoscan_events e
JOIN public.plugin_capabilities pc
ON pc.capability_type = 'scan_source.v1'
AND pc.capability_id = e.capability_id
JOIN public.plugin_installations pi
ON pi.id = pc.plugin_installation_id
WHERE e.plugin_id = ''
GROUP BY e.id
)
UPDATE public.autoscan_events e
SET plugin_id = inferred.plugin_id
FROM inferred
WHERE e.id = inferred.id
AND inferred.matches = 1;
CREATE INDEX idx_autoscan_sources_plugin_capability
ON public.autoscan_sources (plugin_id, capability_id);
CREATE INDEX idx_autoscan_events_plugin_capability_completed
ON public.autoscan_events (plugin_id, capability_id, completed_at DESC);
ALTER TABLE public.autoscan_sources
ALTER COLUMN plugin_id DROP DEFAULT;
ALTER TABLE public.autoscan_events
ALTER COLUMN plugin_id DROP DEFAULT;
ALTER TABLE public.autoscan_sources
DROP COLUMN installation_id;
ALTER TABLE public.autoscan_events
DROP COLUMN installation_id;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.autoscan_sources
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
ALTER TABLE public.autoscan_events
ADD COLUMN installation_id integer NOT NULL DEFAULT 0;
WITH resolved AS (
SELECT s.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
FROM public.autoscan_sources s
JOIN public.plugin_installations pi
ON pi.plugin_id = s.plugin_id
JOIN public.plugin_capabilities pc
ON pc.plugin_installation_id = pi.id
AND pc.capability_type = 'scan_source.v1'
AND pc.capability_id = s.capability_id
WHERE btrim(s.plugin_id) <> ''
GROUP BY s.id
)
UPDATE public.autoscan_sources s
SET installation_id = resolved.installation_id
FROM resolved
WHERE s.id = resolved.id
AND resolved.matches = 1;
WITH resolved AS (
SELECT e.id, min(pc.plugin_installation_id)::integer AS installation_id, count(*) AS matches
FROM public.autoscan_events e
JOIN public.plugin_installations pi
ON pi.plugin_id = e.plugin_id
JOIN public.plugin_capabilities pc
ON pc.plugin_installation_id = pi.id
AND pc.capability_type = 'scan_source.v1'
AND pc.capability_id = e.capability_id
WHERE btrim(e.plugin_id) <> ''
GROUP BY e.id
)
UPDATE public.autoscan_events e
SET installation_id = resolved.installation_id
FROM resolved
WHERE e.id = resolved.id
AND resolved.matches = 1;
DROP INDEX IF EXISTS public.idx_autoscan_events_plugin_capability_completed;
DROP INDEX IF EXISTS public.idx_autoscan_sources_plugin_capability;
ALTER TABLE public.autoscan_events
DROP COLUMN plugin_id;
ALTER TABLE public.autoscan_sources
DROP COLUMN plugin_id;
-- +goose StatementEnd
@@ -0,0 +1,36 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.autoscan_events
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
ALTER TABLE public.autoscan_events
ADD CONSTRAINT autoscan_events_status_check
CHECK (status = ANY (ARRAY[
'running'::text,
'success'::text,
'error'::text,
'unresolved'::text
]));
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
UPDATE public.autoscan_events
SET status = 'error',
error_message = CASE
WHEN error_message = '' THEN 'poll started but did not finish'
ELSE error_message
END
WHERE status = 'running';
ALTER TABLE public.autoscan_events
DROP CONSTRAINT IF EXISTS autoscan_events_status_check;
ALTER TABLE public.autoscan_events
ADD CONSTRAINT autoscan_events_status_check
CHECK (status = ANY (ARRAY[
'success'::text,
'error'::text,
'unresolved'::text
]));
-- +goose StatementEnd
-4
View File
@@ -87,8 +87,6 @@ import WatchTogetherJoin from "@/pages/WatchTogetherJoin";
import WatchTogetherRoomPage from "@/pages/WatchTogetherRoomPage";
import WatchRoute from "@/pages/WatchRoute";
import ProfileCustomizeHome from "@/pages/ProfileCustomizeHome";
import AudiobookLibrary from "@/pages/audiobooks/AudiobookLibrary";
import AudiobookDetail from "@/pages/audiobooks/AudiobookDetail";
import {
WatchPlaybackBar,
WatchPlaybackHost,
@@ -524,8 +522,6 @@ function AppRoutes() {
element={<RecommendationsSection />}
/>
<Route path="/calendar" element={<Calendar />} />
<Route path="/audiobooks" element={<AudiobookLibrary />} />
<Route path="/audiobooks/book/:contentId" element={<AudiobookDetail />} />
<Route
path="/profile/customize-home"
element={<ProfileCustomizeHome />}
+60 -8
View File
@@ -528,6 +528,46 @@ export interface CrewMember {
photo_thumbhash?: string;
}
export interface AudiobookPerson {
person_id?: string;
name: string;
photo_url?: string;
photo_thumbhash?: string;
}
export interface AudiobookRelatedItem {
content_id: string;
title: string;
year?: number;
poster_url?: string;
series_index?: number;
}
export interface AudiobookSeriesGroup {
name?: string;
entries: AudiobookRelatedItem[];
}
export interface AudiobookNarration {
content_id: string;
title: string;
year?: number;
narrators: string[];
}
export interface AudiobookDetailExtension {
authors: AudiobookPerson[];
narrators: AudiobookPerson[];
publisher?: string;
total_duration_seconds: number;
series?: AudiobookSeriesGroup;
other_narrations: AudiobookNarration[];
related: {
also_by_author: AudiobookRelatedItem[];
similar: AudiobookRelatedItem[];
};
}
// Seasons / Watched State
export interface LeafItemUserData {
played: boolean;
@@ -701,6 +741,7 @@ export interface FileVersion {
presentation_kind?: string;
presentation_group_key?: string;
presentation_part_index?: number;
presentation_part_total?: number;
multi_episode_start?: number;
multi_episode_end?: number;
effective_audio_track_index?: number;
@@ -949,6 +990,7 @@ export interface ItemDetail {
effective_version_hdr?: boolean;
effective_version_codec_video?: string;
effective_version_edition_key?: string;
audiobook?: AudiobookDetailExtension;
}
export interface WatchDetail {
@@ -1766,7 +1808,7 @@ export interface AutoscanPathRewrite {
export interface AutoscanSource {
id: string;
installation_id: number;
plugin_id: string;
capability_id: string;
connection_id: string | null;
enabled: boolean;
@@ -1792,9 +1834,8 @@ export interface AutoscanSourcesResponse {
}
export interface AutoscanAvailableSource {
installation_id: number;
capability_id: string;
plugin_id: string;
capability_id: string;
display_name: string;
}
@@ -1803,7 +1844,7 @@ export interface AutoscanAvailableSourcesResponse {
}
export interface AutoscanSourceCreateInput {
installation_id: number;
plugin_id: string;
capability_id: string;
connection_id?: string | null;
enabled: boolean;
@@ -1845,7 +1886,7 @@ export interface AutoscanRewriteSuggestions {
export interface AutoscanStatusSource {
id: string;
installation_id: number;
plugin_id: string;
capability_id: string;
connection_id: string | null;
enabled: boolean;
@@ -1854,16 +1895,27 @@ export interface AutoscanStatusSource {
last_error: string | null;
}
export interface AutoscanRunningPoll {
id: number;
source_id: string | null;
plugin_id: string;
capability_id: string;
started_at: string;
elapsed_ms: number;
marker_before?: string;
}
export interface AutoscanStatus {
enabled: boolean;
sources: AutoscanStatusSource[];
running_polls: AutoscanRunningPoll[];
active_scans: number;
accepted_scans: number;
running_scans: number;
latest_event_at?: string;
}
export type AutoscanEventStatus = "success" | "error" | "unresolved";
export type AutoscanEventStatus = "running" | "success" | "error" | "unresolved";
export interface AutoscanEventScanRun {
id: string;
@@ -1881,7 +1933,7 @@ export interface AutoscanEventScanRun {
export interface AutoscanEvent {
id: number;
source_id: string | null;
installation_id: number;
plugin_id: string;
capability_id: string;
started_at: string;
completed_at: string;
@@ -1919,7 +1971,7 @@ export interface AutoscanScan {
completed_at?: string;
autoscan_event_id?: number;
source_id?: string;
installation_id?: number;
plugin_id?: string;
capability_id?: string;
event_status?: AutoscanEventStatus;
event_completed_at?: string;
-6
View File
@@ -55,12 +55,6 @@ vi.mock("@/hooks/queries/useRequests", () => ({
}),
}));
vi.mock("@/hooks/queries/settings", () => ({
useSettings: () => ({
data: { "audiobooks.enabled": "false" },
}),
}));
vi.mock("@/hooks/useViewTransition", () => ({
useViewTransitionNavigate: () => vi.fn(),
}));
+3 -22
View File
@@ -11,7 +11,6 @@ import { useUserLibraries } from "@/hooks/queries/libraries";
import { usePluginSettingsList } from "@/hooks/queries/pluginSettings";
import { useRequestFeatureStatus } from "@/hooks/queries/useRequests";
import { useSidebarPins, useToggleSidebarPin } from "@/hooks/queries/sidebarPins";
import { useSettings } from "@/hooks/queries/settings";
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
import { pluginRouteHref } from "@/lib/pluginRouteHref";
import {
@@ -65,6 +64,9 @@ function getLibraryIcon(type: string) {
return <Film className="h-[18px] w-[18px]" />;
case "series":
return <Tv className="h-[18px] w-[18px]" />;
case "audiobook":
case "audiobooks":
return <BookHeadphones className="h-[18px] w-[18px]" />;
default:
return <Library className="h-[18px] w-[18px]" />;
}
@@ -167,13 +169,11 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
const { theme, setTheme, previewTheme, resetPreviewTheme } = useTheme();
const isAdmin = user?.role === "admin";
const { data: libraries } = useUserLibraries();
const { data: settings } = useSettings();
const { pins } = useSidebarPins();
const { togglePin } = useToggleSidebarPin();
const { data: pluginSettings } = usePluginSettingsList();
const requestStatus = useRequestFeatureStatus();
const showRequestsNav = requestStatus.data?.requests_enabled === true;
const showAudiobooksNav = settings?.["audiobooks.enabled"] === "true";
const pluginNavLinks = useMemo(() => {
const installations = pluginSettings?.installations ?? [];
const links: { id: string; basePath: string; label: string; pluginId: string }[] = [];
@@ -551,25 +551,6 @@ export default function AppSidebar({ onNavigate, collapsed = false }: AppSidebar
<SidebarLabel show={showLabels}>Calendar</SidebarLabel>
</ViewTransitionLink>
</li>
{showAudiobooksNav && (
<li>
<ViewTransitionLink
to="/audiobooks"
onClick={onNavigate}
className={navLinkClass("/audiobooks")}
aria-current={isActive("/audiobooks") ? "page" : undefined}
>
{isActive("/audiobooks") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<BookHeadphones className="h-[18px] w-[18px] shrink-0" />
<SidebarLabel show={showLabels}>Audiobooks</SidebarLabel>
</ViewTransitionLink>
</li>
)}
</ul>
</div>
+10 -1
View File
@@ -9,6 +9,7 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
interface SharedItemGridProps {
loading?: boolean;
sortField?: string;
libraryId?: number;
selectionMode?: boolean;
selectedIds?: ReadonlySet<string>;
onToggleSelect?: (item: BrowseItem) => void;
@@ -42,7 +43,14 @@ const GRID_CLASSES =
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
export default function ItemGrid(props: ItemGridProps) {
const { loading, sortField, selectionMode = false, selectedIds, onToggleSelect } = props;
const {
loading,
sortField,
libraryId,
selectionMode = false,
selectedIds,
onToggleSelect,
} = props;
const { prefs: overlayPrefs } = useOverlayPrefs();
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
const pages = hasStaticItems(props)
@@ -147,6 +155,7 @@ export default function ItemGrid(props: ItemGridProps) {
<div key={item.content_id} role="listitem">
<ItemCard
item={item}
libraryId={libraryId}
sortField={sortField}
overlayPrefs={overlayPrefs}
selectionMode={selectionMode}
@@ -1,6 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { adminKeys, catalogKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys";
import { invalidateCatalogState } from "./realtimeCatalogInvalidation";
import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider";
const mockState = vi.hoisted(() => ({
@@ -84,6 +86,84 @@ describe("buildEventsUrl", () => {
});
});
describe("invalidateCatalogState", () => {
it("invalidates library lists for a scoped library change", async () => {
const queryClient = new QueryClient();
const otherCatalogKey = catalogKeys.list({
source: "section",
scope: "library",
section_id: "all",
library_id: 1,
limit: 60,
offset: 0,
});
const changedCatalogKey = catalogKeys.list({
source: "section",
scope: "library",
section_id: "all",
library_id: 3,
limit: 60,
offset: 0,
});
const otherSectionKey = sectionKeys.libraryLayout(1);
const changedSectionKey = sectionKeys.libraryLayout(3);
const userLibrariesKey = libraryKeys.user("profile-1");
queryClient.setQueryData(adminKeys.libraries(), []);
queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []);
queryClient.setQueryData(userLibrariesKey, []);
queryClient.setQueryData(otherCatalogKey, { items: [] });
queryClient.setQueryData(changedCatalogKey, { items: [] });
queryClient.setQueryData(otherSectionKey, { sections: [] });
queryClient.setQueryData(changedSectionKey, { sections: [] });
invalidateCatalogState(queryClient, { libraryId: 3, allowDashboardRefetch: false });
await Promise.resolve();
expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(userLibrariesKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherCatalogKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherSectionKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(changedSectionKey)?.isInvalidated).toBe(true);
});
it("can skip library lists for item-scoped catalog changes", async () => {
const queryClient = new QueryClient();
const changedCatalogKey = catalogKeys.list({
source: "section",
scope: "library",
section_id: "all",
library_id: 3,
limit: 60,
offset: 0,
});
queryClient.setQueryData(adminKeys.libraries(), []);
queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []);
queryClient.setQueryData(libraryKeys.all, []);
queryClient.setQueryData(changedCatalogKey, { items: [] });
invalidateCatalogState(queryClient, {
itemId: "item-1",
libraryId: 3,
allowDashboardRefetch: false,
includeLibraryLists: false,
});
await Promise.resolve();
expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe(
false,
);
expect(queryClient.getQueryState(libraryKeys.all)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true);
});
});
describe("RealtimeEventsProvider", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
+37 -33
View File
@@ -18,9 +18,10 @@ import {
type RealtimeConnectionState,
type RealtimeEventsContextValue,
} from "@/components/realtimeEventsContext";
import { invalidateCatalogState } from "@/components/realtimeCatalogInvalidation";
import { useAuth } from "@/hooks/useAuth";
import { usePageActivity } from "@/hooks/usePageActivity";
import { adminKeys, catalogKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
import { adminKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys";
import {
invalidateMediaSurfaceQueries,
updateCatalogItemDetail,
@@ -46,7 +47,12 @@ interface UserStatePayload {
in_watchlist?: boolean;
}
const CATALOG_ITEM_CHANGED_EVENTS = new Set(["metadata.updated", "catalog.item.changed"]);
const CATALOG_ITEM_CHANGED_EVENTS = new Set([
"catalog.item.changed",
"library.item_added",
"metadata.updated",
]);
const SCOPED_CATALOG_LIBRARY_EVENTS = new Set(["catalog.library.changed", "library.changed"]);
const DASHBOARD_QUERY_KEYS = [
adminKeys.stats(),
adminKeys.sessions(),
@@ -178,25 +184,12 @@ function invalidateDashboardQueries(queryClient: QueryClient, allowRefetch: bool
}
}
function invalidateCatalogState(
queryClient: QueryClient,
options: { itemId?: string; allowDashboardRefetch: boolean },
) {
const { itemId, allowDashboardRefetch } = options;
void invalidateMediaSurfaceQueries(queryClient, itemId ? { itemId } : {}).then(() => {
bumpHomeRefreshSignal(queryClient);
});
void queryClient.refetchQueries({ queryKey: catalogKeys.all, type: "active" });
void queryClient.invalidateQueries({
queryKey: adminKeys.libraries(),
refetchType: allowDashboardRefetch ? "active" : "none",
});
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
void queryClient.invalidateQueries({
queryKey: adminKeys.stats(),
refetchType: allowDashboardRefetch ? "active" : "none",
});
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
function catalogEventLibraryID(data: unknown) {
if (!data || typeof data !== "object" || !("library_id" in data)) {
return undefined;
}
const value = (data as { library_id?: unknown }).library_id;
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function handleJobSideEffects(
@@ -473,18 +466,29 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) {
function handleEvent(message: EventsEventMessage) {
switch (message.channel) {
case "catalog":
if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) {
invalidateCatalogState(queryClient, {
itemId:
typeof message.data === "object" && message.data && "content_id" in message.data
? (message.data as { content_id?: string }).content_id
: undefined,
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
});
} else {
invalidateCatalogState(queryClient, {
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
});
{
const eventLibraryID = catalogEventLibraryID(message.data);
if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) {
invalidateCatalogState(queryClient, {
itemId:
typeof message.data === "object" && message.data && "content_id" in message.data
? (message.data as { content_id?: string }).content_id
: undefined,
libraryId: eventLibraryID,
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
includeLibraryLists: false,
});
} else if (SCOPED_CATALOG_LIBRARY_EVENTS.has(message.event) && eventLibraryID) {
invalidateCatalogState(queryClient, {
libraryId: eventLibraryID,
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
});
} else {
invalidateCatalogState(queryClient, {
libraryId: eventLibraryID,
allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current,
});
}
}
break;
case "jobs":
@@ -68,7 +68,9 @@ export default function CollectionRulesEditor({
onChange({
...value,
media_scope:
next === "all" ? undefined : (next as "movie" | "series" | "episode"),
next === "all"
? undefined
: (next as "movie" | "series" | "episode" | "audiobook"),
sort: nextSort,
});
}}
@@ -82,6 +84,7 @@ export default function CollectionRulesEditor({
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
<SelectItem value="audiobook">Audiobooks</SelectItem>
</SelectContent>
</Select>
</div>
@@ -0,0 +1,31 @@
import type { QueryClient } from "@tanstack/react-query";
import { adminKeys, libraryKeys } from "@/hooks/queries/keys";
import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh";
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
export function invalidateCatalogState(
queryClient: QueryClient,
options: {
itemId?: string;
libraryId?: number;
allowDashboardRefetch: boolean;
includeLibraryLists?: boolean;
},
) {
const { itemId, libraryId, allowDashboardRefetch, includeLibraryLists = true } = options;
void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId }).then(() => {
bumpHomeRefreshSignal(queryClient);
});
if (includeLibraryLists) {
void queryClient.invalidateQueries({
queryKey: adminKeys.libraries(),
refetchType: allowDashboardRefetch ? "active" : "none",
});
void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() });
void queryClient.invalidateQueries({ queryKey: libraryKeys.all });
}
void queryClient.invalidateQueries({
queryKey: adminKeys.stats(),
refetchType: allowDashboardRefetch ? "active" : "none",
});
}
@@ -61,8 +61,9 @@ export function SectionSummaryBadges({
<Badge variant="secondary">{recipeLabel(catalog, section.sectionType)}</Badge>
{queryDefinition.media_scope === "movie" ? <Badge variant="outline">Movies</Badge> : null}
{queryDefinition.media_scope === "series" ? <Badge variant="outline">Series</Badge> : null}
{queryDefinition.media_scope === "episode" ? (
<Badge variant="outline">Episodes</Badge>
{queryDefinition.media_scope === "episode" ? <Badge variant="outline">Episodes</Badge> : null}
{queryDefinition.media_scope === "audiobook" ? (
<Badge variant="outline">Audiobooks</Badge>
) : null}
{libraries
? queryDefinition.library_ids.map((libraryId) => {
@@ -477,7 +477,9 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
setQueryDefinition({
...queryDefinition,
media_scope:
value === "all" ? undefined : (value as "movie" | "series" | "episode"),
value === "all"
? undefined
: (value as "movie" | "series" | "episode" | "audiobook"),
})
}
>
@@ -489,6 +491,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
<SelectItem value="audiobook">Audiobooks</SelectItem>
</SelectContent>
</Select>
</div>
-11
View File
@@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { AudiobookDetailResponse } from "@/lib/audiobooks/types";
export function useAudiobook(contentId: string | undefined) {
return useQuery<AudiobookDetailResponse>({
queryKey: ["audiobooks", "detail", contentId],
enabled: Boolean(contentId),
queryFn: () => api<AudiobookDetailResponse>(`/audiobooks/${contentId}`),
});
}
@@ -1,51 +0,0 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { AudiobookListResponse } from "@/lib/audiobooks/types";
interface UseAudiobookLibraryOpts {
limit?: number;
offset?: number;
enabled?: boolean;
}
export function useAudiobookLibrary(opts: UseAudiobookLibraryOpts = {}) {
const { limit = 50, offset = 0, enabled = true } = opts;
return useQuery<AudiobookListResponse>({
queryKey: ["audiobooks", "list", limit, offset],
enabled,
queryFn: () => {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
return api<AudiobookListResponse>(`/audiobooks?${params.toString()}`);
},
});
}
interface UseInfiniteAudiobookLibraryOpts {
limit?: number;
enabled?: boolean;
genre?: string;
}
export function useInfiniteAudiobookLibrary(opts: UseInfiniteAudiobookLibraryOpts = {}) {
const { limit = 60, enabled = true, genre = "" } = opts;
return useInfiniteQuery<AudiobookListResponse>({
queryKey: ["audiobooks", "list-infinite", limit, genre],
enabled,
initialPageParam: 0,
queryFn: ({ pageParam = 0 }) => {
const params = new URLSearchParams({
limit: String(limit),
offset: String(pageParam),
});
if (genre) params.set("genre", genre);
return api<AudiobookListResponse>(`/audiobooks?${params.toString()}`);
},
getNextPageParam: (lastPage) => {
const next = lastPage.offset + lastPage.items.length;
return next < lastPage.total ? next : undefined;
},
});
}
@@ -1,32 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
interface ReportProgressVars {
contentId: string;
positionSeconds: number;
mediaFileId?: number;
}
interface ReportProgressBody {
position_seconds: number;
media_file_id?: number;
}
export function useReportAudiobookProgress() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contentId, positionSeconds, mediaFileId }: ReportProgressVars) => {
const body: ReportProgressBody = { position_seconds: positionSeconds };
if (mediaFileId !== undefined) {
body.media_file_id = mediaFileId;
}
return api(`/audiobooks/${contentId}/progress`, {
method: "POST",
body: JSON.stringify(body),
});
},
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ["audiobooks", "detail", vars.contentId] });
},
});
}
@@ -91,6 +91,51 @@ describe("invalidateMediaSurfaceQueries", () => {
expect(queryClient.getQueryState(catalogKeys.itemDetail("item-1"))?.isInvalidated).toBe(true);
});
it("does not invalidate catalog list queries for a different library scope", async () => {
const queryClient = new QueryClient();
const moviesKey = catalogKeys.list({
source: "section",
scope: "library",
section_id: "all",
library_id: 1,
limit: 60,
offset: 0,
});
const internationalKey = catalogKeys.list({
source: "section",
scope: "library",
section_id: "all",
library_id: 3,
limit: 60,
offset: 0,
});
queryClient.setQueryData(moviesKey, { items: [] });
queryClient.setQueryData(internationalKey, { items: [] });
await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 });
expect(queryClient.getQueryState(moviesKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(internationalKey)?.isInvalidated).toBe(true);
});
it("does not invalidate library section queries for a different library scope", async () => {
const queryClient = new QueryClient();
const moviesLayoutKey = sectionKeys.libraryLayout(1);
const moviesSectionKey = sectionKeys.libraryItems(1, "recently-added");
const internationalLayoutKey = sectionKeys.libraryLayout(3);
queryClient.setQueryData(moviesLayoutKey, { sections: [] });
queryClient.setQueryData(moviesSectionKey, { section: { id: "recently-added", items: [] } });
queryClient.setQueryData(internationalLayoutKey, { sections: [] });
await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 });
expect(queryClient.getQueryState(moviesLayoutKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(moviesSectionKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(internationalLayoutKey)?.isInvalidated).toBe(true);
});
it("sets all cached detail keys for the mutated item", () => {
const queryClient = new QueryClient();
+13 -2
View File
@@ -12,9 +12,14 @@ import {
sectionKeys,
watchlistKeys,
} from "./keys";
import {
activeCatalogQueryMatchesLibrary,
activeSectionQueryMatchesLibrary,
} from "@/lib/queryInvalidation";
interface InvalidateMediaSurfaceOptions {
itemId?: string;
libraryId?: number;
watchedKeys?: Array<readonly unknown[]>;
}
@@ -96,8 +101,14 @@ export async function invalidateMediaSurfaceQueries(
) {
const invalidations: Array<Promise<void>> = [
queryClient.invalidateQueries({ queryKey: itemKeys.all }),
queryClient.invalidateQueries({ queryKey: catalogKeys.all }),
queryClient.invalidateQueries({ queryKey: sectionKeys.all }),
queryClient.invalidateQueries({
queryKey: catalogKeys.all,
predicate: (query) => activeCatalogQueryMatchesLibrary(query.queryKey, options.libraryId),
}),
queryClient.invalidateQueries({
queryKey: sectionKeys.all,
predicate: (query) => activeSectionQueryMatchesLibrary(query.queryKey, options.libraryId),
}),
queryClient.invalidateQueries({ queryKey: progressKeys.all }),
queryClient.invalidateQueries({ queryKey: historyKeys.all }),
queryClient.invalidateQueries({ queryKey: favoriteKeys.all }),
+37 -1
View File
@@ -1,4 +1,4 @@
import { useQuery, useQueries } from "@tanstack/react-query";
import { useMutation, useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api/client";
import type { ProgressListResponse, ProgressEntry, ItemDetail } from "@/api/types";
import { catalogKeys, progressKeys } from "./keys";
@@ -65,3 +65,39 @@ export function useContinueWatching(
isLoading: enabled && progressLoading,
};
}
interface ReportMediaProgressVars {
contentId: string;
positionSeconds: number;
durationSeconds: number;
forceOverwrite?: boolean;
}
export function useReportMediaProgress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
contentId,
positionSeconds,
durationSeconds,
forceOverwrite = true,
}: ReportMediaProgressVars) =>
api("/sync/progress", {
method: "POST",
body: JSON.stringify({
items: [
{
media_item_id: contentId,
position: positionSeconds,
duration: durationSeconds,
force_overwrite: forceOverwrite,
},
],
}),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: progressKeys.all });
queryClient.invalidateQueries({ queryKey: catalogKeys.all });
},
});
}
+4 -4
View File
@@ -17,10 +17,10 @@ interface TasteSeedSubmitResponse {
const TASTE_SEED_PAGE_SIZE = 30;
/**
* Fetches popular catalog posters for the taste-seeding picker. Blends
* server-engagement popularity with TMDB rating so even a fresh server with no
* watch history surfaces meaningful posters. Items already favorited by the
* profile carry user_state.is_favorite=true so the UI can pre-select them.
* Fetches catalog posters for the taste-seeding picker. Blends server engagement
* with rating reliability and recency so even a fresh server with no watch
* history surfaces meaningful posters. Items already favorited by the profile
* carry user_state.is_favorite=true so the UI can pre-select them.
*/
export function useTasteSeedItems(enabled = true) {
return useInfiniteQuery({

Some files were not shown because too many files have changed in this diff Show More