Compare commits

..
Author SHA1 Message Date
rxwatcher 17ec6646a1 fix: clean ebook embedded descriptions 2026-06-08 21:56:19 +02:00
rxwatcher c200ecc061 fix: make ebook reader build clean 2026-06-08 21:32:41 +02:00
rxwatcher bcaa6dc58d fix: include ebook reader vendor in docker build 2026-06-08 21:30:20 +02:00
rxwatcher cfc0d4ac0d fix: encode item API ids 2026-06-08 19:52:35 +02:00
rxwatcher 5adcbbd1e2 fix: encode catalog read API ids 2026-06-08 19:51:01 +02:00
rxwatcher ca55d6f146 fix: make global search prompt media neutral 2026-06-08 19:48:42 +02:00
rxwatcher 28c56a7e59 fix: label ebook search results clearly 2026-06-08 19:48:08 +02:00
rxwatcher 2908fc9a9a test: cover ebook scan format set 2026-06-08 19:47:38 +02:00
rxwatcher 60542f8768 fix: encode recommendation and search item links 2026-06-08 19:46:19 +02:00
rxwatcher f1e3a8df95 fix: encode watch tonight item links 2026-06-08 19:44:09 +02:00
rxwatcher 527ccd64e3 fix: encode hero and continue item links 2026-06-08 19:42:26 +02:00
rxwatcher 45c79f4d82 fix: encode catalog card item links 2026-06-08 19:41:22 +02:00
rxwatcher 78ac6b0551 fix: encode related rail item links 2026-06-08 19:40:11 +02:00
rxwatcher ae42516a91 fix: link ebook genres with library scope 2026-06-08 19:37:06 +02:00
rxwatcher 958abefe53 fix: preserve ebook reader library context 2026-06-08 19:35:58 +02:00
rxwatcher 7a28a8fde9 test: cover ebook unread catalog rules 2026-06-08 19:33:24 +02:00
rxwatcher 3f6105ae00 fix: remove plain text reader loader path 2026-06-08 19:32:28 +02:00
rxwatcher 998174c8f7 fix: label ebook personalized sorts as read 2026-06-08 19:31:24 +02:00
rxwatcher 48422f0fe1 fix: label ebook advanced filters as read 2026-06-08 19:29:17 +02:00
rxwatcher 463b21c683 fix: reject txt ebook reader files 2026-06-08 19:27:06 +02:00
rxwatcher d40dca9022 fix: remove txt ebook reader support 2026-06-08 19:25:11 +02:00
rxwatcher 9676e616ab fix: show ebook related rails as book covers 2026-06-08 19:22:53 +02:00
rxwatcher 544a1e1332 fix: label ebook progress filters as read 2026-06-08 19:21:10 +02:00
rxwatcher be18357469 fix: merge ebook reader progress during item repair 2026-06-08 19:17:12 +02:00
rxwatcher ecde6d6039 fix: scope narrator catalog badges to audiobooks 2026-06-08 19:15:43 +02:00
rxwatcher 9d6216da3a fix: persist non-epub ebook reader progress 2026-06-08 19:14:31 +02:00
rxwatcher 2fbc268124 fix: encode ebook hero reader links 2026-06-08 19:10:12 +02:00
rxwatcher 3c70c0aaae feat: cache embedded ebook covers during scan 2026-06-08 19:09:28 +02:00
rxwatcher 5eb34cbeda fix: detect archive ebook reader formats by filename 2026-06-08 19:04:56 +02:00
rxwatcher a7569e6d40 fix: route ebook hero actions to reader 2026-06-08 19:01:55 +02:00
rxwatcher a8afc04580 fix: route ebook watch tonight cards to reader 2026-06-08 18:59:18 +02:00
rxwatcher 8edb5c761a fix: hide watched toggle for ebooks 2026-06-08 18:57:34 +02:00
rxwatcher 6ab4458efa fix: route ebook continue cards to reader 2026-06-08 18:56:26 +02:00
rxwatcher f305e4c6a1 fix: add ebook library default sections 2026-06-08 18:53:35 +02:00
rxwatcher c406b56b98 fix: honor profile header for ebook item progress 2026-06-08 18:51:46 +02:00
rxwatcher 900f7adb77 fix: support ebook subtree scans 2026-06-08 18:51:46 +02:00
rxwatcher 783d6751dd fix: expose ebook reader progress on item detail 2026-06-08 18:49:09 +02:00
rxwatcher 5ad2fe9b9e fix: include ebook activity in recommendations 2026-06-08 18:47:13 +02:00
rxwatcher c809d8139b feat: report ebook scan progress 2026-06-08 18:41:51 +02:00
rxwatcher 9f238f2b8e feat: share ebook progress state across item surfaces 2026-06-08 18:40:08 +02:00
rxwatcher b249d53aaf feat: reflect ebook reader progress in item user state 2026-06-08 18:36:49 +02:00
rxwatcher c35fa8bbee fix: include ebooks in last watched catalog filters 2026-06-08 18:34:58 +02:00
rxwatcher 2a30d8a517 fix: filter ebook catalogs by reader progress 2026-06-08 18:33:40 +02:00
rxwatcher 45acc59196 fix: sort ebook catalogs by reader progress 2026-06-08 18:31:41 +02:00
rxwatcher 92ffbfd9ce fix: fall back from unsupported ebook reader files 2026-06-08 18:28:16 +02:00
rxwatcher d083dd44c2 fix: filter ebook asin provider aliases 2026-06-08 18:26:58 +02:00
rxwatcher ffec643a90 fix: read ebook isbn from epub metadata 2026-06-08 18:25:56 +02:00
rxwatcher 5ac4f812b2 feat: include ebooks in catalog progress metrics 2026-06-08 18:24:35 +02:00
rxwatcher 67d5bf7dfe feat: include ebooks in continue watching sections 2026-06-08 18:21:05 +02:00
rxwatcher 433535593f feat: include ebook progress in recommendation signals 2026-06-08 18:18:16 +02:00
rxwatcher 11cc37faf9 fix: include ebooks in recently added recommendations 2026-06-08 18:13:42 +02:00
rxwatcher 9b2ce9588a fix: include ebooks in recommendation media mix 2026-06-08 18:11:18 +02:00
rxwatcher 52a21e48da fix: include ebooks in embedding eligibility 2026-06-08 18:09:55 +02:00
rxwatcher fb5af3ea00 fix: build ebook recommendation text from authors 2026-06-08 18:06:14 +02:00
rxwatcher 70ba2f132d fix: reject narrator queries for ebooks 2026-06-08 18:04:15 +02:00
rxwatcher c41d8f1641 fix: scope ebook catalog facets 2026-06-08 18:02:58 +02:00
rxwatcher a3486f7671 fix: authorize fbz ebooks from compound filename 2026-06-08 17:59:37 +02:00
rxwatcher 48566514fc fix: detect fbz ebooks from compound filename 2026-06-08 17:58:39 +02:00
rxwatcher 7b599970af fix: serve fbz ebook archives with reader mime type 2026-06-08 17:57:33 +02:00
rxwatcher f014f55e6f feat: filter ebook reader file selector 2026-06-08 17:56:05 +02:00
rxwatcher 7ab24d85e7 feat: hide ebook read action for unsupported files 2026-06-08 17:54:59 +02:00
rxwatcher 3b3e1fbbde feat: sync ebook reader progress cache 2026-06-08 17:53:12 +02:00
rxwatcher 0bebbf96c7 feat: surface ebook reader progress 2026-06-08 17:49:06 +02:00
rxwatcher 3db6babfad feat: prefer epub for ebook read action 2026-06-08 17:45:11 +02:00
rxwatcher 95c1802528 feat: switch ebook reader files 2026-06-08 17:43:46 +02:00
rxwatcher 2b29356e76 feat: show ebook download summaries 2026-06-08 17:41:54 +02:00
rxwatcher f4ed50c896 feat: show ebook file page counts 2026-06-08 17:40:21 +02:00
rxwatcher 9a8babdb75 feat: count cbz ebook pages 2026-06-08 17:36:56 +02:00
rxwatcher dad9e21563 feat: extract fbz ebook metadata 2026-06-08 17:34:21 +02:00
rxwatcher 648e65bdc3 feat: favor scanner isbn during ebook enrichment 2026-06-08 17:30:23 +02:00
rxwatcher 4f139a195a feat: extract ebook pdf metadata 2026-06-08 17:24:59 +02:00
rxwatcher c53fbd3442 feat: add ebook reader controls 2026-06-08 17:02:37 +02:00
rxwatcher 72650c147f feat: persist ebook reader progress 2026-06-08 16:56:08 +02:00
rxwatcher 776c2ce034 feat: render ebooks with foliate reader 2026-06-08 15:43:38 +02:00
rxwatcher a67015a808 feat: add ebook reader file entrypoint 2026-06-08 15:39:20 +02:00
rxwatcher e34d1ff47a feat: add ebook detail related content 2026-06-08 14:22:57 +02:00
rxwatcher 82df5d063a chore: omit ebook planning docs from branch 2026-06-08 14:11:14 +02:00
rxwatcher 94f5dd8ac2 fix: force ebook people refresh for stale narrators 2026-06-08 14:09:17 +02:00
rxwatcher 46af6746b3 fix: drop asin provider ids from ebook enrichment 2026-06-08 14:03:15 +02:00
rxwatcher 53a78d1290 fix: align ebook collection filters with book media 2026-06-08 14:01:05 +02:00
rxwatcher 08d13552d7 fix: drop narrator credits from ebook scanner merges 2026-06-08 13:57:41 +02:00
rxwatcher 8ad14501cd test: cover ebook detail authors and downloads 2026-06-08 13:56:02 +02:00
rxwatcher 8161a70a09 fix: include catalog safeguards for local ebook integration 2026-06-08 13:54:44 +02:00
rxwatcher b7d05cdee9 feat: use file language in download dialog 2026-06-08 13:34:33 +02:00
rxwatcher 025009c40a feat: use file-size copy for downloads 2026-06-08 13:34:33 +02:00
rxwatcher 82f6e97e66 feat: label ebook file versions by format 2026-06-08 13:34:33 +02:00
rxwatcher f8e3f2dac8 feat: add ebook detail view 2026-06-08 13:34:33 +02:00
rxwatcher 334e2f7b06 feat: add ebook catalog scope support 2026-06-08 13:34:33 +02:00
rxwatcher cd6b0afb34 feat: expose ebook library metadata setup 2026-06-08 13:33:43 +02:00
rxwatcher f734d7a01c feat: wire ebook metadata sync task 2026-06-08 13:33:43 +02:00
rxwatcher ddd579767b fix: harden ebook poster cache 2026-06-08 13:33:43 +02:00
rxwatcher 3018622e5e feat: add ebook metadata enricher 2026-06-08 13:33:43 +02:00
rxwatcher 9e7189f074 docs: clarify ebook foundation PR scope 2026-06-08 13:31:28 +02:00
rxwatcher 21170cfcf3 fix: address ebook scanner PR review 2026-06-08 12:29:35 +02:00
rxwatcher 0e1c2995a3 test: cover ebook series persistence decisions 2026-06-08 12:03:16 +02:00
rxwatcher 98d213204a feat: persist ebook series membership 2026-06-08 11:56:29 +02:00
rxwatcher 5ea8868047 fix: refresh ebook scan metadata safely 2026-06-08 11:52:20 +02:00
rxwatcher 0b97d2bd6d fix: preserve ebook scan people credits 2026-06-08 11:47:18 +02:00
rxwatcher 293db1f255 feat: scan ebook libraries in core 2026-06-08 11:40:31 +02:00
rxwatcher 1119cef018 fix: guard ebook subtree scans 2026-06-08 11:34:14 +02:00
rxwatcher c91a1d5b29 fix: handle ebook isbn labels 2026-06-08 11:31:45 +02:00
rxwatcher f15423d505 fix: harden ebook scanner foundation 2026-06-08 11:31:45 +02:00
rxwatcher 6de2b19c07 feat: add ebook scanner parser foundation 2026-06-08 11:31:45 +02:00
rxwatcher adf595cbf9 docs: plan ebook audiobook-parity implementation 2026-06-08 11:31:45 +02:00
rxwatcher 0b44952816 docs: define ebook architecture matching audiobooks 2026-06-08 11:31:45 +02:00
zZebrahzandGitHub 96bae2f346 fix(library): clean orphaned provisional items (#76)
* fix(library): clean orphaned provisional items

* test(metadata): use item not found sentinel in fake repo

* fix(library): preserve abs references during orphan cleanup

* fix(library): avoid dropped abs cleanup tables

* fix(library): harden provisional orphan cleanup

* fix(library): preserve home dismissal series orphans

* fix(library): Delete matched items created after the first orphan sweep
2026-06-07 21:32:40 -04:00
978e1b4954 feat(playback): NVENC support for transcoding (#79)
* feat(playback): NVENC support for transcoding

* fix(playback): probe nvenc before auto-selecting

* fix(playback): use safe NVENC smoke probe dimensions

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 21:27:56 -04:00
zZebrahzandGitHub c4e67effc3 fix(admin): correct dashboard library counts and scans (#78)
* fix(admin): correct dashboard library counts and scans

* fix(admin): handle mixed library dashboard counts

* fix(admin): refine dashboard count affordances
2026-06-07 19:59:10 -04: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
471 changed files with 28018 additions and 3030 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
+1
View File
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
+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.
+49 -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"
@@ -47,6 +47,7 @@ import (
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/database"
"github.com/Silo-Server/silo-server/internal/ebooks"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/imagecache"
@@ -425,6 +426,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 +528,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 {
@@ -880,6 +889,7 @@ func main() {
var seasonRepo *catalog.SeasonRepository
var episodeRepo *catalog.EpisodeRepository
var audiobookEnricher *audiobooks.Enricher
var ebookEnricher *ebooks.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
@@ -961,6 +971,14 @@ func main() {
personRepo,
providerIDRepo,
)
ebookEnricher = ebooks.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
// Always wire the image resolver so plugin-prefixed URLs (e.g.
// metadb://) can be resolved to presigned HTTP URLs in API responses.
@@ -983,6 +1001,9 @@ func main() {
audiobookEnricher.SetImageCacher(imageCacher)
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
}
if ebookEnricher != nil {
ebookEnricher.SetImageCacher(imageCacher)
}
}
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
@@ -1435,6 +1456,7 @@ func main() {
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
if deps.IntroAnalyzer != nil {
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
}
@@ -1490,6 +1512,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,9 +1544,12 @@ 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 ebookEnricher != nil {
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
if err != nil {
@@ -1542,7 +1570,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 +1912,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 +2014,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
+1 -1
View File
@@ -33,6 +33,7 @@ require (
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
@@ -71,7 +72,6 @@ require (
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
+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),
})
}
}
+44 -8
View File
@@ -22,7 +22,9 @@ type AdminStats struct {
TotalFiles int `json:"total_files"`
TotalUsers int `json:"total_users"`
TotalMovies int `json:"total_movies"`
TotalMovieFiles int `json:"total_movie_files"`
TotalShows int `json:"total_shows"`
TotalShowFiles int `json:"total_show_files"`
ActiveStreams int `json:"active_streams"`
TotalStorageBytes int64 `json:"total_storage_bytes"`
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
}
var (
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalShows int64
activeStreams int64
totalStorage int64
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalMovieFiles int64
totalShows int64
totalShowFiles int64
activeStreams int64
totalStorage int64
)
row := pool.QueryRow(ctx, `
@@ -151,8 +155,34 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
file_stats AS (
SELECT
COUNT(*)::bigint AS total_files,
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
FROM media_files
FROM (
SELECT
media_files.file_size,
CASE
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
THEN 'series'
WHEN episodes.content_id IS NOT NULL
THEN 'series'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
ELSE ''
END AS file_kind
FROM media_files
JOIN media_folders ON media_folders.id = media_files.media_folder_id
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
) classified_files
),
session_stats AS (
SELECT COUNT(*)::bigint AS active_streams
@@ -163,7 +193,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
item_stats.total_items,
file_stats.total_files,
item_stats.total_movies,
file_stats.total_movie_files,
item_stats.total_shows,
file_stats.total_show_files,
session_stats.active_streams,
file_stats.total_storage_bytes
FROM user_stats
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
&totalItems,
&totalFiles,
&totalMovies,
&totalMovieFiles,
&totalShows,
&totalShowFiles,
&activeStreams,
&totalStorage,
); err != nil {
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
TotalItems: int(totalItems),
TotalFiles: int(totalFiles),
TotalMovies: int(totalMovies),
TotalMovieFiles: int(totalMovieFiles),
TotalShows: int(totalShows),
TotalShowFiles: int(totalShowFiles),
ActiveStreams: int(activeStreams),
TotalStorageBytes: totalStorage,
WatchProviderActivity: activity,
-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)
}
+5
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/sections"
@@ -89,6 +90,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
h.itemsH.accessFilter(r),
overlaySummaries,
store,
apimw.GetUserID(r.Context()),
profileID,
)
items := make([]itemListResponse, 0, len(result.Items))
@@ -221,6 +223,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
}
+2 -2
View File
@@ -497,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
case "movie", "episode", "audiobook", "ebook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
+380
View File
@@ -0,0 +1,380 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// EbookReaderProgress is the persisted reader position for one profile and ebook.
type EbookReaderProgress struct {
UserID int `json:"-"`
ProfileID string `json:"-"`
ContentID string `json:"content_id"`
FileID int `json:"file_id"`
Location string `json:"location"`
Progress float64 `json:"progress"`
UpdatedAt time.Time `json:"updated_at"`
}
type EbookReaderProgressStore interface {
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
Upsert(ctx context.Context, progress EbookReaderProgress) error
}
// EbookReaderHandler serves ebook files for the in-app reader.
type EbookReaderHandler struct {
FileAuthorizer *MediaFileAuthorizer
ProgressStore EbookReaderProgressStore
}
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
return &EbookReaderHandler{FileAuthorizer: authorizer}
}
// HandleReadFile serves an ebook file inline with byte-range support.
func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Request) {
if apimw.GetUserID(r.Context()) == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.FileAuthorizer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
fileID, err := strconv.Atoi(chi.URLParam(r, "file_id"))
if contentID == "" || err != nil || fileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "content_id and file_id are required")
return
}
file, err := h.FileAuthorizer.Authorize(r, fileID)
if err != nil {
h.writeReadError(w, err)
return
}
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
if err := serveEbookInline(w, r, file); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve ebook file")
}
}
type ebookReaderProgressRequest struct {
FileID int `json:"file_id"`
Location string `json:"location"`
Progress float64 `json:"progress"`
}
func (h *EbookReaderHandler) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil || h.FileAuthorizer.ItemAccess == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
if err := h.FileAuthorizer.ItemAccess.EnsureAccessible(r.Context(), contentID, requestAccessFilter(r)); err != nil {
h.writeReadError(w, err)
return
}
progress, err := h.ProgressStore.Get(r.Context(), userID, profileID, contentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load ebook progress")
return
}
if progress == nil {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
writeJSON(w, http.StatusOK, progress)
}
func (h *EbookReaderHandler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h == nil || h.ProgressStore == nil || h.FileAuthorizer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader progress is not configured")
return
}
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
var req ebookReaderProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.Location = strings.TrimSpace(req.Location)
if req.FileID <= 0 || req.Location == "" || req.Progress < 0 || req.Progress > 1 {
writeError(w, http.StatusBadRequest, "bad_request", "file_id, location, and progress are required")
return
}
file, err := h.FileAuthorizer.Authorize(r, req.FileID)
if err != nil {
h.writeReadError(w, err)
return
}
if file == nil || file.ContentID != contentID || !isEbookFile(file) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
}
progress := EbookReaderProgress{
UserID: userID,
ProfileID: profileID,
ContentID: contentID,
FileID: req.FileID,
Location: req.Location,
Progress: req.Progress,
UpdatedAt: time.Now().UTC(),
}
if err := h.ProgressStore.Upsert(r.Context(), progress); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save ebook progress")
return
}
writeJSON(w, http.StatusOK, progress)
}
func (h *EbookReaderHandler) writeReadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize ebook file")
}
}
func serveEbookInline(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
f, err := os.Open(file.FilePath)
if err != nil {
if os.IsNotExist(err) {
return catalog.ErrItemNotFound
}
return fmt.Errorf("opening ebook file: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("stat ebook file: %w", err)
}
filename := sanitizeInlineFilename(filepath.Base(file.FilePath))
w.Header().Set("Content-Type", ebookMimeType(file.FilePath, file.Container))
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
return nil
}
func isEbookFile(file *models.MediaFile) bool {
if file == nil {
return false
}
if !strings.EqualFold(file.BaseType, "ebook") {
return false
}
if strings.HasSuffix(strings.ToLower(file.FilePath), ".fb2.zip") {
return true
}
return isEbookReaderFormat(file.Container) || isEbookReaderFormat(filepath.Ext(file.FilePath))
}
func isEbookReaderFormat(value string) bool {
format := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), ".")
switch format {
case "epub", "pdf", "mobi", "azw", "azw3", "cbz", "cbr", "fb2", "fbz", "md":
return true
default:
return false
}
}
func ebookMimeType(path, container string) string {
ext := strings.ToLower(filepath.Ext(path))
if strings.HasSuffix(strings.ToLower(path), ".fb2.zip") {
ext = ".fbz"
} else if ext == "" && container != "" {
ext = "." + strings.TrimPrefix(strings.ToLower(container), ".")
}
switch ext {
case ".epub":
return "application/epub+zip"
case ".pdf":
return "application/pdf"
case ".mobi":
return "application/x-mobipocket-ebook"
case ".azw":
return "application/vnd.amazon.ebook"
case ".azw3":
return "application/vnd.amazon.mobi8-ebook"
case ".cbz":
return "application/vnd.comicbook+zip"
case ".cbr":
return "application/vnd.comicbook-rar"
case ".fb2":
return "application/x-fictionbook+xml"
case ".fbz":
return "application/x-zip-compressed-fb2"
case ".md":
return "text/markdown; charset=utf-8"
default:
return "application/octet-stream"
}
}
func sanitizeInlineFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "." || name == string(filepath.Separator) {
return "ebook"
}
name = strings.ReplaceAll(name, `"`, "")
name = strings.ReplaceAll(name, "\n", " ")
name = strings.ReplaceAll(name, "\r", " ")
return name
}
type PGEbookReaderProgressStore struct {
pool *pgxpool.Pool
}
func NewPGEbookReaderProgressStore(pool *pgxpool.Pool) *PGEbookReaderProgressStore {
return &PGEbookReaderProgressStore{pool: pool}
}
func (s *PGEbookReaderProgressStore) Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader progress store is not configured")
}
var progress EbookReaderProgress
err := s.pool.QueryRow(ctx, `
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
FROM ebook_reader_progress
WHERE user_id = $1 AND profile_id = $2 AND content_id = $3`,
userID, profileID, contentID,
).Scan(
&progress.UserID,
&progress.ProfileID,
&progress.ContentID,
&progress.FileID,
&progress.Location,
&progress.Progress,
&progress.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get ebook reader progress: %w", err)
}
return &progress, nil
}
func (s *PGEbookReaderProgressStore) ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error) {
if s == nil || s.pool == nil {
return nil, fmt.Errorf("ebook reader progress store is not configured")
}
if userID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT user_id, profile_id, content_id, file_id, location, progress, updated_at
FROM ebook_reader_progress
WHERE user_id = $1 AND profile_id = $2 AND content_id = ANY($3::text[])`,
userID, profileID, contentIDs,
)
if err != nil {
return nil, fmt.Errorf("list ebook reader progress: %w", err)
}
defer rows.Close()
progressByContentID := make(map[string]EbookReaderProgress, len(contentIDs))
for rows.Next() {
var progress EbookReaderProgress
if err := rows.Scan(
&progress.UserID,
&progress.ProfileID,
&progress.ContentID,
&progress.FileID,
&progress.Location,
&progress.Progress,
&progress.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan ebook reader progress: %w", err)
}
progressByContentID[progress.ContentID] = progress
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ebook reader progress: %w", err)
}
return progressByContentID, nil
}
func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookReaderProgress) error {
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO ebook_reader_progress
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
file_id = EXCLUDED.file_id,
location = EXCLUDED.location,
progress = EXCLUDED.progress,
updated_at = EXCLUDED.updated_at`,
progress.UserID,
progress.ProfileID,
progress.ContentID,
progress.FileID,
progress.Location,
progress.Progress,
progress.UpdatedAt,
); err != nil {
return fmt.Errorf("upsert ebook reader progress: %w", err)
}
return nil
}
+328
View File
@@ -0,0 +1,328 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func newEbookReaderAuthRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
routeCtx.URLParams.Add("file_id", fileID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
type fakeEbookProgressStore struct {
progress *EbookReaderProgress
upserted *EbookReaderProgress
err error
}
func (s *fakeEbookProgressStore) Get(
context.Context,
int,
string,
string,
) (*EbookReaderProgress, error) {
return s.progress, s.err
}
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
if s.err != nil {
return s.err
}
s.upserted = &progress
return nil
}
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.epub")
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: filePath,
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req.Header.Set("Range", "bytes=2-5")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusPartialContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("content-type = %q", got)
}
if got := rr.Header().Get("Content-Disposition"); got != `inline; filename="book.epub"` {
t.Fatalf("content-disposition = %q", got)
}
if rr.Body.String() != "2345" {
t.Fatalf("body = %q", rr.Body.String())
}
}
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Container: "mkv",
BaseType: "movie",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
req = withEbookReaderRouteParams(req, "movie-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
cases := map[string]string{
"book.epub": "application/epub+zip",
"book.pdf": "application/pdf",
"book.mobi": "application/x-mobipocket-ebook",
"book.azw": "application/vnd.amazon.ebook",
"book.azw3": "application/vnd.amazon.mobi8-ebook",
"book.cbz": "application/vnd.comicbook+zip",
"book.cbr": "application/vnd.comicbook-rar",
"book.fb2": "application/x-fictionbook+xml",
"book.fbz": "application/x-zip-compressed-fb2",
"book.fb2.zip": "application/x-zip-compressed-fb2",
"book.md": "text/markdown; charset=utf-8",
"book.unknown": "application/octet-stream",
}
for name, wantMime := range cases {
t.Run(name, func(t *testing.T) {
container := name
if name == "book.fb2.zip" {
container = "fbz"
}
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
if name == "book.unknown" {
if isEbookFile(file) {
t.Fatal("unknown extension should not be treated as an ebook reader format")
}
return
}
file.BaseType = "ebook"
if !isEbookFile(file) {
t.Fatal("expected ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
}
})
}
}
func TestEbookReaderRejectsPlainText(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.txt",
BaseType: "ebook",
}
if isEbookFile(file) {
t.Fatal("plain text should not be treated as an ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
t.Fatal("plain text should not have an ebook reader MIME type")
}
}
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.fb2.zip",
BaseType: "ebook",
}
if !isEbookFile(file) {
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
}
}
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
store := &fakeEbookProgressStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = store
req := httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/progress",
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "user",
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.upserted == nil {
t.Fatal("expected progress to be saved")
}
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
}
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
}
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
t.Fatalf("progress = %+v", store.upserted)
}
}
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = &fakeEbookProgressStore{
progress: &EbookReaderProgress{
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
FileID: 42,
Location: "epubcfi(/6/8)",
Progress: 0.75,
UpdatedAt: updatedAt,
},
}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
`"content_id":"ebook-1"`,
`"file_id":42`,
`"location":"epubcfi(/6/8)"`,
`"progress":0.75`,
`"updated_at":"2026-06-08T12:00:00Z"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response %s missing %s", body, want)
}
}
}
+9 -1
View File
@@ -40,6 +40,7 @@ type PersonalDataHandler struct {
localFavoriteDispatcher LocalFavoriteEventDispatcher
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
ebookProgressStore EbookReaderProgressLister
}
// NewPersonalDataHandler creates a new PersonalDataHandler.
@@ -55,6 +56,10 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
h.detailSvc = svc
}
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
h.ebookProgressStore = store
}
// SetProfileStaler configures an optional staleness trigger for taste profiles.
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
h.profileStaler = ps
@@ -552,7 +557,10 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
userStates := map[string]*itemUserStateResponse{}
if store, profileID, ok := h.userStoreForRequest(r); ok {
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
}); err == nil {
userStates = resolvedStates
}
}
+86 -7
View File
@@ -46,6 +46,10 @@ type LocalWatchEventDispatcher interface {
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
}
type EbookReaderProgressLister interface {
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
}
// ItemsHandler handles browse, search, item detail, and series endpoints.
type ItemsHandler struct {
browseRepo *catalog.BrowseRepository
@@ -62,6 +66,7 @@ type ItemsHandler struct {
profileRefreshRequester ProfileRefreshRequester
metadataRefreshRequester MetadataRefreshRequester
localWatchDispatcher LocalWatchEventDispatcher
ebookProgressStore EbookReaderProgressLister
EventsHub *evt.Hub
UserRepo *auth.UserRepository
}
@@ -114,6 +119,10 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
h.localWatchDispatcher = dispatcher
}
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
h.ebookProgressStore = store
}
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
return
@@ -373,8 +382,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
}
}
if detail.Type == "movie" || detail.Type == "episode" {
detail.UserData = h.getLeafUserData(r, detail.ContentID)
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
}
@@ -739,7 +748,10 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
if !ok {
return map[string]*itemUserStateResponse{}
}
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
@@ -850,6 +862,7 @@ func (h *ItemsHandler) listSortMetrics(
filter catalog.AccessFilter,
overlaySummaries map[string]*models.OverlaySummary,
store userstore.UserStore,
userID int,
profileID string,
) map[string]*sortMetricsResponse {
metrics := make(map[string]*sortMetricsResponse, len(items))
@@ -890,7 +903,7 @@ func (h *ItemsHandler) listSortMetrics(
}
}
case "progress", "date_viewed", "plays":
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
}
return metrics
}
@@ -900,6 +913,7 @@ func (h *ItemsHandler) listUserSortMetrics(
items []*models.MediaItem,
sortField string,
store userstore.UserStore,
userID int,
profileID string,
metrics map[string]*sortMetricsResponse,
) {
@@ -915,6 +929,10 @@ func (h *ItemsHandler) listUserSortMetrics(
if err != nil {
return
}
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
if err != nil {
return
}
switch sortField {
case "progress":
@@ -923,10 +941,16 @@ func (h *ItemsHandler) listUserSortMetrics(
continue
}
progress, ok := progressMap[item.ContentID]
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
ratio := progress.PositionSeconds / progress.DurationSeconds
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
continue
}
ratio := progress.PositionSeconds / progress.DurationSeconds
ebookProgress, ok := ebookProgressMap[item.ContentID]
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= 0.9 {
continue
}
ratio := ebookProgress.Progress
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
}
case "date_viewed", "plays":
@@ -955,6 +979,12 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
viewedAt = progress.UpdatedAt
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= 0.9 {
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
if ebookViewedAt > viewedAt {
viewedAt = ebookViewedAt
}
}
if viewedAt == "" {
continue
}
@@ -964,6 +994,9 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
playCount = 1
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= 0.9 && playCount < 1 {
playCount = 1
}
if playCount <= 0 {
continue
}
@@ -974,6 +1007,18 @@ func (h *ItemsHandler) listUserSortMetrics(
}
}
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
ctx context.Context,
userID int,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
}
func uniqueItemContentIDs(items []*models.MediaItem) []string {
contentIDs := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
@@ -1110,7 +1155,11 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
return resp
}
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
if len(itemType) > 0 && itemType[0] == "ebook" {
return h.getEbookLeafUserData(r, contentID)
}
store, profileID, ok := h.userStoreForRequest(r)
if !ok {
return nil
@@ -1134,6 +1183,36 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
}
}
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
if h == nil || h.ebookProgressStore == nil {
return nil
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if userID <= 0 || profileID == "" || contentID == "" {
return nil
}
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
if err != nil {
return nil
}
row, ok := progress[contentID]
if !ok || row.Progress <= 0 {
return nil
}
return &catalog.SeasonUserData{
PositionSeconds: row.Progress,
DurationSeconds: 1,
IsInProgress: row.Progress < 0.9,
Played: row.Progress >= 0.9,
}
}
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
return
@@ -40,20 +40,86 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
}
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
progress := progressMetrics["movie-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
plays := playsMetrics["movie-plays"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
t.Fatalf("plays metrics = %#v", plays)
}
}
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.37,
UpdatedAt: updatedAt,
},
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: updatedAt.Add(time.Hour),
},
},
},
}
items := []*models.MediaItem{
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
progress := progressMetrics["ebook-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
plays := playsMetrics["ebook-complete"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
t.Fatalf("plays metrics = %#v", plays)
}
}
type fakeEbookReaderProgressLister struct {
progress map[string]EbookReaderProgress
}
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
_ context.Context,
_ int,
_ string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
result := make(map[string]EbookReaderProgress, len(contentIDs))
for _, contentID := range contentIDs {
if progress, ok := f.progress[contentID]; ok {
result[contentID] = progress
}
}
return result, nil
}
@@ -0,0 +1,47 @@
package handlers
import (
"net/http/httptest"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
},
"ebook-complete": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.93,
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
},
},
},
}
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
t.Fatalf("partial ebook user data = %#v", progress)
}
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
if complete == nil || !complete.Played || complete.IsInProgress {
t.Fatalf("completed ebook user data = %#v", complete)
}
}
+19 -10
View File
@@ -2029,16 +2029,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return nil
}
// Determine which content levels apply.
var levels []string
switch libraryType {
case "series":
levels = []string{"series", "season", "episode"}
case "movies", "movie":
levels = []string{"movie"}
case "mixed":
levels = []string{"movie", "series", "season", "episode"}
default:
levels := metadataContentLevelsForLibraryType(libraryType)
if len(levels) == 0 {
return nil
}
@@ -2091,6 +2083,23 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return entries
}
func metadataContentLevelsForLibraryType(libraryType string) []string {
switch libraryType {
case "series":
return []string{"series", "season", "episode"}
case "movies", "movie":
return []string{"movie"}
case "audiobooks", "audiobook":
return []string{"audiobook"}
case "ebooks", "ebook":
return []string{"ebook"}
case "mixed":
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
default:
return nil
}
}
// HandleListStaleIDs handles GET /libraries/stale-ids.
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
if h.StaleIDRepo == nil {
@@ -0,0 +1,26 @@
package handlers
import (
"reflect"
"testing"
)
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
cases := []struct {
name string
libraryType string
want []string
}{
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
}
})
}
}
+5 -1
View File
@@ -48,6 +48,7 @@ type RecommendationsHandler struct {
EpisodeRepo *catalog.EpisodeRepository
WatchTonightFetcher watchTonightSectionFetcher
CastFetcher cardsCastFetcher
EbookProgress EbookReaderProgressLister
// RecWorker enqueues asynchronous taste-profile refreshes after writes
// (taste seeding). Optional — when nil, refresh is simply skipped.
RecWorker ProfileRefreshRequester
@@ -643,7 +644,10 @@ func (h *RecommendationsHandler) loadItemEnrichment(
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
out.states = states
}
+5 -1
View File
@@ -34,6 +34,7 @@ type SectionHandler struct {
DetailSvc *catalog.DetailService
Settings *catalog.ServerSettingsRepo
CollectionRepo *catalog.LibraryCollectionRepository
EbookProgress EbookReaderProgressLister
}
// NewSectionHandler creates a new SectionHandler.
@@ -1377,7 +1378,10 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
if err != nil || store == nil {
return map[string]*itemUserStateResponse{}
}
states, err := resolveItemUserStates(r.Context(), store, profileID, h.EpisodeRepo, items)
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.EpisodeRepo, items, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
+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)
}
+9 -6
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{}})
@@ -181,7 +181,10 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
if err != nil || store == nil {
return nil
}
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return nil
}
+34
View File
@@ -14,12 +14,28 @@ type itemUserStateResponse struct {
InWatchlist bool `json:"in_watchlist"`
}
type itemUserStateOptions struct {
UserID int
EbookProgressStore EbookReaderProgressLister
}
func resolveItemUserStates(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
) (map[string]*itemUserStateResponse, error) {
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
}
func resolveItemUserStatesWithOptions(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
options itemUserStateOptions,
) (map[string]*itemUserStateResponse, error) {
result := make(map[string]*itemUserStateResponse, len(items))
if store == nil || profileID == "" || len(items) == 0 {
@@ -111,6 +127,10 @@ func resolveItemUserStates(
if err != nil {
return nil, err
}
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
if err != nil {
return nil, err
}
for _, item := range items {
if item == nil || item.ContentID == "" {
@@ -125,6 +145,8 @@ func resolveItemUserStates(
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
case "season":
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
case "ebook":
state.Played = ebookProgressMap[item.ContentID].Progress >= 0.9
default:
state.Played = progressMap[item.ContentID].Completed
}
@@ -134,6 +156,18 @@ func resolveItemUserStates(
return result, nil
}
func resolveEbookProgressForUserStates(
ctx context.Context,
options itemUserStateOptions,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
}
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
if len(episodes) == 0 {
return false
+50
View File
@@ -0,0 +1,50 @@
package handlers
import (
"context"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
items := []*models.MediaItem{
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
}
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
},
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.45,
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
},
},
},
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
}
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
}
}
+4 -1
View File
@@ -362,7 +362,10 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
stateMap = states
}
+44 -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.
@@ -370,7 +368,10 @@ func NewRouter(deps Dependencies) chi.Router {
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
var autoscanHandler *handlers.AutoscanHandler
var ebookReaderHandler *handlers.EbookReaderHandler
var ebookProgressStore *handlers.PGEbookReaderProgressStore
if deps.DB != nil {
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
@@ -421,6 +422,19 @@ func NewRouter(deps Dependencies) chi.Router {
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
}
if ebookProgressStore != nil {
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
if deps.FileRepo != nil {
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
})
if ebookProgressStore != nil {
ebookReaderHandler.ProgressStore = ebookProgressStore
}
}
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
catalogHandler = handlers.NewCatalogHandler(
catalog.NewCatalogResolver(browseRepo, itemRepo).
@@ -500,6 +514,9 @@ func NewRouter(deps Dependencies) chi.Router {
if detailSvc != nil {
personalDataHandler.SetDetailService(detailSvc)
}
if ebookProgressStore != nil {
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
personalDataHandler.SetEpisodeRepo(episodeRepo)
personalDataHandler.SetSeasonRepo(seasonRepo)
personalDataHandler.EventsHub = deps.EventsHub
@@ -579,6 +596,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 +680,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 +719,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
@@ -935,6 +956,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
sectionHandler.EpisodeRepo = episodeRepo
sectionHandler.DetailSvc = detailSvc
if ebookProgressStore != nil {
sectionHandler.EbookProgress = ebookProgressStore
}
if userRepo != nil {
sectionHandler.UserRepo = userRepo
}
@@ -1115,6 +1139,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
recsHandler.CalendarRepo = calendarRepo
recsHandler.EpisodeRepo = episodeRepo
if ebookProgressStore != nil {
recsHandler.EbookProgress = ebookProgressStore
}
if deps.PersonRepo != nil {
recsHandler.CastFetcher = deps.PersonRepo
}
@@ -1701,6 +1728,16 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
if ebookReaderHandler != nil {
r.Route("/ebooks", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
})
}
// Subtitle search + AI translation routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
@@ -1809,20 +1846,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 +1943,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 +2137,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
+13 -6
View File
@@ -759,9 +759,9 @@ func listDistinctPeopleByKindWithSource(
}
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
// from audiobook_series joined onto the scoped result set. Names are trimmed
// from the active book series table joined onto the scoped result set. Names are trimmed
// and case-folded for sort so the picker doesn't show duplicates that differ
// only by whitespace or casing — the literal trimmed series_name is still
// only by whitespace or casing; the literal trimmed series_name is still
// returned so existing rules continue to match.
//
// The DISTINCT happens in an inline subquery (rather than directly on the
@@ -783,14 +783,14 @@ func listDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), catalogFacetMaxValues)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -946,7 +946,7 @@ func searchDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
@@ -954,10 +954,17 @@ func searchDistinctAudiobookSeriesWithSource(
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), prefixIdx, limit+1)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
func bookSeriesTableForMediaScope(mediaScope string) string {
if mediaScope == "ebook" {
return "ebook_series"
}
return "audiobook_series"
}
// queryFacetSearchResults executes a facet search query that was built
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
// indicating whether an additional row was present (i.e. the result set
+1 -1
View File
@@ -390,7 +390,7 @@ func normalizeCatalogMatch(raw string) string {
func parseCatalogMediaScope(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "movie", "series", "episode", "audiobook":
case "movie", "series", "episode", "audiobook", "ebook":
return strings.ToLower(strings.TrimSpace(raw))
default:
return ""
+9
View File
@@ -0,0 +1,9 @@
package catalog
import "testing"
func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) {
if got := parseCatalogMediaScope(" ebook "); got != "ebook" {
t.Fatalf("expected ebook media scope, got %q", got)
}
}
+24 -19
View File
@@ -39,10 +39,9 @@ type CatalogFiltersResult struct {
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// Authors, Narrators and Series are audiobook-native facets. Populated
// whenever the scope contains audiobook items; empty for video-only
// scopes. They share the item_people / audiobook_series tables so
// callers don't need to switch on media type.
// Authors, Narrators and Series are book-native facets. Authors and
// Narrators use item_people; Series uses the active book series table.
// Video-only scopes return these empty.
Authors []string
Narrators []string
Series []string
@@ -64,8 +63,8 @@ type facetFetcher interface {
// PeopleByKind returns distinct people names for the given PersonKind
// across the scoped result set. Used for Authors / Narrators facets.
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// AudiobookSeries returns distinct series_name values from
// audiobook_series joined onto the scoped result set.
// AudiobookSeries returns distinct series_name values from the active
// book series table joined onto the scoped result set.
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// SearchDistinctArrayColumn prefix-searches an array-column facet
// (genres, studios, networks, countries). Returns up to limit
@@ -710,7 +709,7 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
}
return r.listFiltersForSource(ctx, filters, options, "media_items mi", "")
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
@@ -798,10 +797,9 @@ func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, a
}
baseRelation := "media_items mi"
mediaScope := ""
mediaScope := req.Query.MediaScope
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
mediaScope = req.Query.MediaScope
}
facets := r.facets
@@ -837,6 +835,9 @@ func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string,
case "author":
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
case "narrator":
if mediaScope == "ebook" {
return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest)
}
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
case "series":
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
@@ -958,14 +959,16 @@ func (r *CatalogResolver) listFiltersForSource(
authors = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
if mediaScope != "ebook" {
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
}
eg.Go(withLimit(func() error {
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
if err != nil {
@@ -1089,8 +1092,10 @@ 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" &&
def.MediaScope != "ebook" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
+165
View File
@@ -39,6 +39,21 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) {
}
}
func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{
MediaScope: "ebook",
Match: "all",
Sort: QuerySort{Field: "title", Order: "asc"},
},
}
if err := validateCatalogQueryRequest(req, true); err != nil {
t.Fatalf("expected ebook media scope to be accepted, got %v", err)
}
}
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
@@ -244,6 +259,67 @@ func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters Br
return nil, false, nil
}
type recordingFacetFetcher struct {
mu sync.Mutex
peopleKinds []models.PersonKind
searchPeopleKind models.PersonKind
searchMediaScope string
}
func (s *recordingFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
s.mu.Lock()
s.peopleKinds = append(s.peopleKinds, kind)
s.mu.Unlock()
if kind == models.PersonKindAuthor {
return []string{"Author"}, nil
}
return []string{"Narrator"}, nil
}
func (s *recordingFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.mu.Lock()
s.searchPeopleKind = kind
s.searchMediaScope = mediaScope
s.mu.Unlock()
return []string{"Author"}, false, nil
}
func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
// countingExecutor is a previewExecutor stub that records the number of
// PreviewPage calls and the AccessFilter passed in (so tests can confirm
// NamePrefix made it through). It returns an empty result set so the resolver
@@ -327,3 +403,92 @@ func TestListFiltersWithOptions_RunsFacetQueriesConcurrently(t *testing.T) {
barrier.assertConcurrent(t)
}
func TestListFiltersWithOptions_EbookScopeSkipsNarratorFacet(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.ListFiltersWithOptions(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
CatalogFilterOptions{},
)
if err != nil {
t.Fatalf("ListFiltersWithOptions returned error: %v", err)
}
facets.mu.Lock()
defer facets.mu.Unlock()
for _, kind := range facets.peopleKinds {
if kind == models.PersonKindNarrator {
t.Fatal("ebook filters should not request narrator facets")
}
}
if len(result.Narrators) != 0 {
t.Fatalf("ebook filters returned narrators = %v, want empty", result.Narrators)
}
}
func TestSearchFacet_EbookScopeForwardsMediaScope(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"author",
"Au",
20,
)
if err != nil {
t.Fatalf("SearchFacet returned error: %v", err)
}
if len(result.Matches) != 1 || result.Matches[0] != "Author" {
t.Fatalf("matches = %v, want Author", result.Matches)
}
facets.mu.Lock()
defer facets.mu.Unlock()
if facets.searchPeopleKind != models.PersonKindAuthor {
t.Fatalf("searchPeopleKind = %v, want author", facets.searchPeopleKind)
}
if facets.searchMediaScope != "ebook" {
t.Fatalf("search mediaScope = %q, want ebook", facets.searchMediaScope)
}
}
func TestSearchFacet_EbookScopeRejectsNarratorFacet(t *testing.T) {
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: &recordingFacetFetcher{},
}
_, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"narrator",
"Na",
20,
)
if err == nil {
t.Fatal("expected narrator facet to be rejected for ebook scope")
}
}
+485
View File
@@ -154,6 +154,61 @@ 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"`
// Ebook-specific detail. Present only when Type == "ebook".
Ebook *EbookDetailExtension `json:"ebook,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"`
}
type EbookDetailExtension struct {
Authors []AudiobookPerson `json:"authors"`
Publisher string `json:"publisher,omitempty"`
Series *AudiobookSeriesGroup `json:"series,omitempty"`
Related AudiobookRelatedContent `json:"related"`
}
// ItemUserState is per-profile viewer state included in item detail responses.
@@ -224,6 +279,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 +889,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 +902,13 @@ 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)
}
if item.Type == "ebook" {
detail.Ebook = s.buildEbookExtension(ctx, item, 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 +1018,400 @@ 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 (s *DetailService) buildEbookExtension(
ctx context.Context,
item *models.MediaItem,
crew []CrewCredit,
filter AccessFilter,
) *EbookDetailExtension {
if item == nil {
return nil
}
return &EbookDetailExtension{
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
Publisher: firstNonEmptyString(item.Studios),
Series: s.fetchEbookSeries(ctx, item.ContentID, filter),
Related: AudiobookRelatedContent{
AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter),
Similar: s.fetchEbookSimilarByGenres(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 {
return s.fetchBookAlsoByAuthor(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookAlsoByAuthor(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookAlsoByAuthor(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"ip1.content_id = $1",
"ip1.kind = $2",
"m2.type = $3",
}
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 {
return s.fetchBookSimilarByGenres(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookSimilarByGenres(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookSimilarByGenres(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"m.type = $3",
"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 {
return s.fetchBookSeries(ctx, contentID, "audiobook", "audiobook_series", filter)
}
func (s *DetailService) fetchEbookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
return s.fetchBookSeries(ctx, contentID, "ebook", "ebook_series", filter)
}
func (s *DetailService) fetchBookSeries(ctx context.Context, contentID string, mediaType string, tableName string, filter AccessFilter) *AudiobookSeriesGroup {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return nil
}
switch tableName {
case "audiobook_series", "ebook_series":
default:
return nil
}
args := []any{contentID, mediaType}
argIdx := 3
conditions := []string{
"root.content_id = $1",
"m.type = $2",
}
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 %s root
JOIN %s 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
`, tableName, tableName, 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 +2027,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 +2106,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),
+66
View File
@@ -0,0 +1,66 @@
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)
}
}
}
func TestBuildEbookExtensionUsesAuthorsAndPublisherWithoutNarrators(t *testing.T) {
crew := []CrewCredit{
{Name: "Becky Chambers", Job: models.PersonKindAuthor.String(), PersonID: "10"},
{Name: "Narrator Should Not Apply", Job: models.PersonKindNarrator.String(), PersonID: "20"},
}
item := &models.MediaItem{
ContentID: "ebook-1",
Type: "ebook",
Studios: []string{"Tor"},
}
extension := (&DetailService{}).buildEbookExtension(nil, item, crew, AccessFilter{})
if extension == nil {
t.Fatal("buildEbookExtension returned nil")
}
if len(extension.Authors) != 1 || extension.Authors[0].Name != "Becky Chambers" {
t.Fatalf("Authors = %+v, want Becky Chambers", extension.Authors)
}
if extension.Publisher != "Tor" {
t.Fatalf("Publisher = %q, want Tor", extension.Publisher)
}
}
+64 -28
View File
@@ -36,10 +36,10 @@ type RatingFilter struct {
Filter AccessFilter
}
// ListByRatingThreshold returns media items whose rating_imdb is >= f.Min,
// ordered by rating_imdb DESC NULLS LAST. Items without an IMDb rating are
// always excluded.
func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f RatingFilter) ([]*models.MediaItem, error) {
// buildRatingThresholdQuery builds the SQL statement and bind args for ListByRatingThreshold.
// It returns an empty query string when access rules exclude every
// library, signalling the caller to skip the query and return no rows.
func buildRatingThresholdQuery(f RatingFilter) (string, []any) {
var conditions []string
var args []any
argIdx := 1
@@ -60,7 +60,7 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
return "", nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
@@ -85,7 +85,7 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
qualifiedItemColumns("mi"),
fromClause,
strings.Join(conditions, " AND "),
)
@@ -95,6 +95,18 @@ func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f Ratin
args = append(args, f.Limit)
}
return query, args
}
// ListByRatingThreshold returns media items whose rating_imdb is >= f.Min,
// ordered by rating_imdb DESC NULLS LAST. Items without an IMDb rating are
// always excluded.
func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f RatingFilter) ([]*models.MediaItem, error) {
query, args := buildRatingThresholdQuery(f)
if query == "" {
return []*models.MediaItem{}, nil
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing items by rating threshold: %w", err)
@@ -122,17 +134,10 @@ type UnplayedFilter struct {
Filter AccessFilter
}
// ListUnplayedHighRated returns high-rated items that the given user/profile has
// never started watching. "Never started" means no row exists in
// user_watch_history for (user_id, profile_id, media_item_id), regardless of
// completion status. Items without an IMDb rating are excluded.
//
// Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f UnplayedFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListUnplayedHighRated: UserID and ProfileID are required")
}
// buildUnplayedHighRatedQuery builds the SQL statement and bind args for ListUnplayedHighRated.
// It returns an empty query string when access rules exclude every
// library, signalling the caller to skip the query and return no rows.
func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) {
var conditions []string
var args []any
argIdx := 1
@@ -161,7 +166,7 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
return "", nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
@@ -186,7 +191,7 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
qualifiedItemColumns("mi"),
fromClause,
strings.Join(conditions, " AND "),
)
@@ -196,6 +201,25 @@ func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f Unpla
args = append(args, f.Limit)
}
return query, args
}
// ListUnplayedHighRated returns high-rated items that the given user/profile has
// never started watching. "Never started" means no row exists in
// user_watch_history for (user_id, profile_id, media_item_id), regardless of
// completion status. Items without an IMDb rating are excluded.
//
// Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f UnplayedFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListUnplayedHighRated: UserID and ProfileID are required")
}
query, args := buildUnplayedHighRatedQuery(f)
if query == "" {
return []*models.MediaItem{}, nil
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing unplayed high-rated items: %w", err)
@@ -225,13 +249,10 @@ type ForgottenFavoritesFilter struct {
Filter AccessFilter
}
// ListForgottenFavorites returns high-rated items (rating_imdb >= 7.0) that the
// user/profile either has never watched OR last watched more than LookbackDays
// ago. Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f ForgottenFavoritesFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListForgottenFavorites: UserID and ProfileID are required")
}
// buildForgottenFavoritesQuery builds the SQL statement and bind args for ListForgottenFavorites.
// It returns an empty query string when access rules exclude every
// library, signalling the caller to skip the query and return no rows.
func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) {
if f.LookbackDays <= 0 {
f.LookbackDays = 365
}
@@ -261,7 +282,7 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
return "", nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
@@ -286,7 +307,7 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
qualifiedItemColumns("mi"),
fromClause,
strings.Join(conditions, " AND "),
)
@@ -296,6 +317,21 @@ func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f Forg
args = append(args, f.Limit)
}
return query, args
}
// ListForgottenFavorites returns high-rated items (rating_imdb >= 7.0) that the
// user/profile either has never watched OR last watched more than LookbackDays
// ago. Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f ForgottenFavoritesFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListForgottenFavorites: UserID and ProfileID are required")
}
query, args := buildForgottenFavoritesQuery(f)
if query == "" {
return []*models.MediaItem{}, nil
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing forgotten favorites: %w", err)
+38 -180
View File
@@ -13,130 +13,6 @@ import (
"testing"
)
// buildRatingThresholdQuery is the testable extraction of the SQL generation
// logic from ListByRatingThreshold so we can inspect it without a pool.
func buildRatingThresholdQuery(f RatingFilter) (string, []any) {
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, "mi.rating_imdb >= $1")
args = append(args, f.Min)
argIdx++
fromClause := "media_items mi"
if f.LibraryID != nil {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
conditions = append(conditions, "mil.media_folder_id = $2")
args = append(args, *f.LibraryID)
argIdx++
} else if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return "", nil // early empty
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := "SELECT " + itemColumns + " FROM " + fromClause +
" WHERE " + strings.Join(conditions, " AND ") +
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
if f.Limit > 0 {
query += " LIMIT $" + itoa(argIdx)
args = append(args, f.Limit)
}
return query, args
}
// buildUnplayedHighRatedQuery mirrors the SQL-building portion of
// ListUnplayedHighRated for unit testing.
func buildUnplayedHighRatedQuery(f UnplayedFilter) (string, []any) {
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, "mi.rating_imdb >= $1")
args = append(args, f.MinRating)
argIdx++
conditions = append(conditions, "NOT EXISTS (\n\t\tSELECT 1\n\t\tFROM user_watch_history uwh\n\t\tWHERE uwh.user_id = $"+itoa(argIdx)+"\n\t\t AND uwh.profile_id = $"+itoa(argIdx+1)+"\n\t\t AND uwh.media_item_id = mi.content_id\n\t)")
args = append(args, f.UserID, f.ProfileID)
argIdx += 2
fromClause := "media_items mi"
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return "", nil // early empty
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := "SELECT " + itemColumns + " FROM " + fromClause +
" WHERE " + strings.Join(conditions, " AND ") +
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
if f.Limit > 0 {
query += " LIMIT $" + itoa(argIdx)
args = append(args, f.Limit)
}
return query, args
}
// itoa is a local helper to avoid importing strconv.
func itoa(n int) string {
if n == 0 {
return "0"
}
buf := [20]byte{}
pos := len(buf)
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
return string(buf[pos:])
}
// ---------------------------------------------------------------------------
// ListByRatingThreshold SQL generation tests
// ---------------------------------------------------------------------------
@@ -370,62 +246,6 @@ func TestUnplayedHighRated_ContentRatingFilter(t *testing.T) {
// ListForgottenFavorites SQL generation tests
// ---------------------------------------------------------------------------
// buildForgottenFavoritesQuery is the testable extraction of the SQL
// generation logic from ListForgottenFavorites.
func buildForgottenFavoritesQuery(f ForgottenFavoritesFilter) (string, []any) {
if f.LookbackDays <= 0 {
f.LookbackDays = 365
}
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, "mi.rating_imdb >= 7.0")
conditions = append(conditions, "NOT EXISTS (\n\t\tSELECT 1\n\t\tFROM user_watch_history uwh\n\t\tWHERE uwh.user_id = $"+itoa(argIdx)+"\n\t\t AND uwh.profile_id = $"+itoa(argIdx+1)+"\n\t\t AND uwh.media_item_id = mi.content_id\n\t\t AND uwh.watched_at >= NOW() - ($"+itoa(argIdx+2)+" || ' days')::interval\n\t)")
args = append(args, f.UserID, f.ProfileID, f.LookbackDays)
argIdx += 3
fromClause := "media_items mi"
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return "", nil // early empty
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id IN ("+strings.Join(placeholders, ", ")+")")
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = "$" + itoa(argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, "mil.media_folder_id NOT IN ("+strings.Join(placeholders, ", ")+")")
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := "SELECT " + itemColumns + " FROM " + fromClause +
" WHERE " + strings.Join(conditions, " AND ") +
" ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC"
if f.Limit > 0 {
query += " LIMIT $" + itoa(argIdx)
args = append(args, f.Limit)
}
return query, args
}
func TestForgottenFavorites_BasicQuery(t *testing.T) {
query, args := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
@@ -540,3 +360,41 @@ func TestForgottenFavorites_DefaultLookbackApplied(t *testing.T) {
t.Fatalf("expected lookback default 365, got %v", args[2])
}
}
// TestDiscoveryQueries_QualifyContentIDUnderLibraryJoin is a regression guard
// for the "column reference \"content_id\" is ambiguous" (SQLSTATE 42702)
// failure. When a viewer has library restrictions, each discovery query joins
// media_item_libraries (which also has a content_id column). The SELECT list
// must therefore qualify its columns with the mi alias; a bare column list
// makes content_id ambiguous and the query fails at runtime.
func TestDiscoveryQueries_QualifyContentIDUnderLibraryJoin(t *testing.T) {
restricted := AccessFilter{AllowedLibraryIDs: []int{1, 2}}
cases := []struct {
name string
query string
}{
{"rating_threshold", mustQuery(buildRatingThresholdQuery(RatingFilter{Min: 7, Filter: restricted}))},
{"unplayed_high_rated", mustQuery(buildUnplayedHighRatedQuery(UnplayedFilter{MinRating: 7, UserID: 1, ProfileID: "p", Filter: restricted}))},
{"forgotten_favorites", mustQuery(buildForgottenFavoritesQuery(ForgottenFavoritesFilter{UserID: 1, ProfileID: "p", LookbackDays: 365, Filter: restricted}))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Precondition: the join that introduces the ambiguity is present.
if !strings.Contains(tc.query, "JOIN media_item_libraries mil") {
t.Fatalf("expected library join in query, got:\n%s", tc.query)
}
// The SELECT list must be alias-qualified.
if !strings.HasPrefix(tc.query, "SELECT mi.content_id,") {
t.Fatalf("SELECT list is not qualified with the mi alias (ambiguous content_id), got:\n%s", tc.query)
}
// And must not contain a bare, unqualified content_id column.
if strings.Contains(tc.query, "SELECT content_id,") {
t.Fatalf("SELECT list uses an unqualified content_id, got:\n%s", tc.query)
}
})
}
}
func mustQuery(query string, _ []any) string { return query }
+13
View File
@@ -3,6 +3,7 @@ package catalog
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -136,3 +137,15 @@ func TestDeleteInBatchesReturnsError(t *testing.T) {
t.Fatalf("expected sentinel, got %v", err)
}
}
func TestLateOrphanCleanupUsesProvisionalSafetyPredicate(t *testing.T) {
conditions := normalizePredicateSQL(orphanedProvisionalMediaItemConditions)
for name, query := range map[string]string{
"collect": collectOrphanedProvisionalIDsByContentIDSQL,
"delete": deleteOrphanedProvisionalIDsByContentIDSQL,
} {
if !strings.Contains(normalizePredicateSQL(query), conditions) {
t.Fatalf("%s late orphan query does not use provisional safety predicate", name)
}
}
}
+142 -22
View File
@@ -563,16 +563,6 @@ func (r *FolderRepository) DeleteWithStats(
}
}
// Filter accumulated image dirs once, now that orphans are gone, against
// any surviving content. Empty deleting-set means "exclude nothing".
if len(rawDirs) > 0 {
filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{})
if err != nil {
return nil, err
}
stats.OrphanedImageDirs = filtered
}
// Phase 2: delete this folder's media_files (folder-tied, not item-tied).
if progress != nil {
progress(orphanTotal, orphanTotal, "Deleting media files")
@@ -592,23 +582,30 @@ func (r *FolderRepository) DeleteWithStats(
}
// Phase 3: delete remaining folder memberships (shared items kept; only the
// membership in this folder is removed).
// membership in this folder is removed). Deleting the memberships with
// RETURNING lets us catch skeleton items that a matcher created after the
// phase-1 orphan sweep but before the delete job reached this phase.
if progress != nil {
progress(orphanTotal, orphanTotal, "Removing library memberships")
}
if _, err := deleteInBatches(ctx, folderChildDeleteBatch, func(ctx context.Context) (int64, error) {
tag, e := r.pool.Exec(ctx, `
DELETE FROM media_item_libraries
WHERE ctid IN (
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
)`, id, folderChildDeleteBatch)
if e != nil {
return 0, e
}
return tag.RowsAffected(), nil
}); err != nil {
lateOrphans, lateImageDirs, err := r.deleteFolderMemberships(ctx, id)
if err != nil {
return nil, fmt.Errorf("deleting media item links: %w", err)
}
stats.OrphanedItems += lateOrphans
for _, d := range lateImageDirs {
rawDirs[d] = struct{}{}
}
// Filter accumulated image dirs once, now that all item orphans are gone,
// against any surviving content. Empty deleting-set means "exclude nothing".
if len(rawDirs) > 0 {
filtered, err := filterUnreferencedImageDirs(ctx, r.pool, dirSetToSlice(rawDirs), []string{})
if err != nil {
return nil, err
}
stats.OrphanedImageDirs = filtered
}
// Phase 4: delete the now-lightweight folder row. Tolerate 0 rows so a
// resumed run that already removed it still succeeds.
@@ -656,6 +653,129 @@ func (r *FolderRepository) collectOrphanBatch(ctx context.Context, folderID, lim
return ids, nil
}
// deleteFolderMemberships removes this folder's remaining item memberships and
// deletes any content that becomes orphaned as a result. This is intentionally
// separate from the phase-1 orphan sweep because in-flight matchers can create
// new skeleton memberships after phase 1 has already drained.
func (r *FolderRepository) deleteFolderMemberships(ctx context.Context, folderID int) (int, []string, error) {
var orphaned int
var imageDirs []string
for {
var contentIDs []string
if err := retryOnDeadlock(ctx, func() error {
ids, err := r.deleteFolderMembershipBatch(ctx, folderID, folderChildDeleteBatch)
if err != nil {
return err
}
contentIDs = ids
return nil
}); err != nil {
return orphaned, imageDirs, err
}
if len(contentIDs) > 0 {
deleted, dirs, err := r.deleteOrphanedItemsByContentID(ctx, contentIDs)
if err != nil {
return orphaned, imageDirs, err
}
orphaned += deleted
imageDirs = append(imageDirs, dirs...)
}
if len(contentIDs) < folderChildDeleteBatch {
return orphaned, imageDirs, nil
}
}
}
func (r *FolderRepository) deleteFolderMembershipBatch(ctx context.Context, folderID, limit int) ([]string, error) {
rows, err := r.pool.Query(ctx, `
DELETE FROM media_item_libraries
WHERE ctid IN (
SELECT ctid FROM media_item_libraries WHERE media_folder_id = $1 LIMIT $2
)
RETURNING content_id`, folderID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var contentIDs []string
for rows.Next() {
var contentID string
if err := rows.Scan(&contentID); err != nil {
return nil, fmt.Errorf("scanning deleted membership content_id: %w", err)
}
contentIDs = append(contentIDs, contentID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return contentIDs, nil
}
const collectOrphanedProvisionalIDsByContentIDSQL = `
SELECT mi.content_id
FROM public.media_items mi
WHERE mi.content_id = ANY($1)
AND ` + orphanedProvisionalMediaItemConditions
const deleteOrphanedProvisionalIDsByContentIDSQL = `
DELETE FROM public.media_items mi
WHERE mi.content_id = ANY($1)
AND ` + orphanedProvisionalMediaItemConditions
func (r *FolderRepository) deleteOrphanedItemsByContentID(ctx context.Context, contentIDs []string) (int, []string, error) {
if len(contentIDs) == 0 {
return 0, nil, nil
}
orphanIDs, err := r.collectOrphanedProvisionalIDsByContentID(ctx, contentIDs)
if err != nil {
return 0, nil, err
}
if len(orphanIDs) == 0 {
return 0, nil, nil
}
imageDirs, err := collectImageDirs(ctx, r.pool, orphanIDs)
if err != nil {
return 0, nil, err
}
var deleted int64
if err := retryOnDeadlock(ctx, func() error {
tag, err := r.pool.Exec(ctx, deleteOrphanedProvisionalIDsByContentIDSQL, orphanIDs)
if err != nil {
return err
}
deleted = tag.RowsAffected()
return nil
}); err != nil {
return 0, nil, err
}
return int(deleted), imageDirs, nil
}
func (r *FolderRepository) collectOrphanedProvisionalIDsByContentID(ctx context.Context, contentIDs []string) ([]string, error) {
rows, err := r.pool.Query(ctx, collectOrphanedProvisionalIDsByContentIDSQL, contentIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var orphanIDs []string
for rows.Next() {
var contentID string
if err := rows.Scan(&contentID); err != nil {
return nil, fmt.Errorf("scanning orphan content_id: %w", err)
}
orphanIDs = append(orphanIDs, contentID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return orphanIDs, nil
}
// dirSetToSlice returns the keys of set as a slice (nil if empty).
func dirSetToSlice(set map[string]struct{}) []string {
if len(set) == 0 {
+200
View File
@@ -0,0 +1,200 @@
package catalog
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultOrphanedProvisionalCleanupBatchSize = 1000
const orphanedMediaItemSafetyConditions = `NOT EXISTS (
SELECT 1 FROM public.media_item_libraries mil
WHERE mil.content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.media_files mf
WHERE mf.content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.episodes e
WHERE e.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.seasons s
WHERE s.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.library_collection_items lci
WHERE lci.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.abs_bookmarks ab
WHERE ab.library_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.abs_playback_sessions aps
WHERE aps.content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.abs_rss_feeds arf
WHERE arf.library_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.downloads d
WHERE d.content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.playback_history_admin pha
WHERE pha.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.plex_sync_item_bindings psib
WHERE psib.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.plex_sync_item_state psis
WHERE psis.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.podcast_feeds pf
WHERE pf.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_favorites uf
WHERE uf.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_downloads ud
WHERE ud.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_history_hidden_items uhhi
WHERE uhhi.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_home_item_dismissals uhid
WHERE uhid.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_home_item_dismissals uhid_series
WHERE uhid_series.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_audio_preferences uap
WHERE uap.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_personal_collection_items upci
WHERE upci.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_ratings ur
WHERE ur.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_series_playback_preferences uspp
WHERE uspp.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_subtitle_preferences usp
WHERE usp.series_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_watch_history uwh
WHERE uwh.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_watch_progress uwp
WHERE uwp.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.user_watchlist uwl
WHERE uwl.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_provider_favorite_items wpfi
WHERE wpfi.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_provider_history_exports wphe
WHERE wphe.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_provider_scrobble_sessions wpss
WHERE wpss.media_item_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_together_rooms wtr
WHERE wtr.selected_content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.watch_together_suggestions wts
WHERE wts.content_id = mi.content_id
)
AND NOT EXISTS (
SELECT 1 FROM public.webhook_sync_item_state wsis
WHERE wsis.media_item_id = mi.content_id
)`
const orphanedProvisionalMediaItemConditions = `mi.status IN ('pending', 'unmatched', 'ambiguous')
AND ` + orphanedMediaItemSafetyConditions
const orphanedProvisionalMediaItemPredicate = `
WHERE ` + orphanedProvisionalMediaItemConditions
type OrphanedProvisionalCleanupStats struct {
Candidates int
Deleted int
}
type OrphanedProvisionalCleaner struct {
pool *pgxpool.Pool
}
func NewOrphanedProvisionalCleaner(pool *pgxpool.Pool) *OrphanedProvisionalCleaner {
return &OrphanedProvisionalCleaner{pool: pool}
}
func (c *OrphanedProvisionalCleaner) Cleanup(ctx context.Context, batchSize int) (OrphanedProvisionalCleanupStats, error) {
var stats OrphanedProvisionalCleanupStats
if c == nil || c.pool == nil {
return stats, fmt.Errorf("orphaned provisional cleanup is not configured")
}
if batchSize <= 0 {
batchSize = defaultOrphanedProvisionalCleanupBatchSize
}
if err := c.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM public.media_items mi `+orphanedProvisionalMediaItemPredicate,
).Scan(&stats.Candidates); err != nil {
return stats, fmt.Errorf("counting orphaned provisional media items: %w", err)
}
for {
tag, err := c.pool.Exec(ctx, `
WITH candidates AS (
SELECT mi.content_id
FROM public.media_items mi
`+orphanedProvisionalMediaItemPredicate+`
ORDER BY mi.content_id ASC
LIMIT $1
)
DELETE FROM public.media_items mi
USING candidates c
WHERE mi.content_id = c.content_id
`, batchSize)
if err != nil {
return stats, fmt.Errorf("deleting orphaned provisional media items: %w", err)
}
deleted := int(tag.RowsAffected())
stats.Deleted += deleted
if deleted < batchSize {
break
}
}
return stats, nil
}
+41
View File
@@ -0,0 +1,41 @@
package catalog
import (
"strings"
"testing"
)
func normalizePredicateSQL(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func TestOrphanedProvisionalPredicatePreservesDurableMediaItemReferences(t *testing.T) {
predicate := normalizePredicateSQL(orphanedProvisionalMediaItemPredicate)
for _, want := range []string{
"public.abs_bookmarks ab WHERE ab.library_item_id = mi.content_id",
"public.abs_playback_sessions aps WHERE aps.content_id = mi.content_id",
"public.abs_rss_feeds arf WHERE arf.library_item_id = mi.content_id",
"public.episodes e WHERE e.series_id = mi.content_id",
"public.podcast_feeds pf WHERE pf.media_item_id = mi.content_id",
"public.seasons s WHERE s.series_id = mi.content_id",
"public.user_audio_preferences uap WHERE uap.series_id = mi.content_id",
"public.user_home_item_dismissals uhid WHERE uhid.media_item_id = mi.content_id",
"public.user_home_item_dismissals uhid_series WHERE uhid_series.series_id = mi.content_id",
"public.user_personal_collection_items upci WHERE upci.media_item_id = mi.content_id",
"public.user_series_playback_preferences uspp WHERE uspp.series_id = mi.content_id",
"public.user_subtitle_preferences usp WHERE usp.series_id = mi.content_id",
} {
if !strings.Contains(predicate, normalizePredicateSQL(want)) {
t.Fatalf("cleanup predicate missing durable reference guard %q", want)
}
}
for _, droppedTable := range []string{
"abs_collection_items",
"abs_playlist_items",
"abs_playlists",
} {
if strings.Contains(predicate, droppedTable) {
t.Fatalf("cleanup predicate must not reference dropped legacy table %q", droppedTable)
}
}
}
+130 -9
View File
@@ -159,9 +159,10 @@ func (qb *QueryBuilder) UserHistoryCTEArgs() []any {
// UserHistoryCTESQL returns the user_last_watched CTE definition. The two
// placeholder positions (user_id, profile_id) are bound to the args returned
// by UserHistoryCTEArgs and should be the first two entries in the final
// statement's arg list. The CTE aggregates user_watch_history watch_at and
// user_watch_progress.completed=TRUE updated_at into one row per
// media_item_id, filtering out items the user has hidden via
// statement's arg list. The CTE aggregates user_watch_history watched_at,
// user_watch_progress.completed=TRUE updated_at, and completed ebook reader
// progress updated_at into one row per media_item_id, filtering out videos the
// user has hidden via
// user_history_hidden_items. The single GROUP BY replaces the two
// correlated MAX subqueries the previous lastWatchedExpr emitted (audit
// 2026-05-01 §3.1 Pattern B).
@@ -187,6 +188,10 @@ func UserHistoryCTESQL(argIdx int) string {
SELECT uwp.media_item_id, NULL::timestamptz, uwp.updated_at
FROM user_watch_progress uwp
WHERE uwp.user_id = $%d AND uwp.profile_id = $%d AND uwp.completed = TRUE
UNION ALL
SELECT erp.content_id AS media_item_id, NULL::timestamptz, erp.updated_at
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d AND erp.profile_id = $%d AND erp.progress >= 0.9
) src
WHERE NOT EXISTS (
SELECT 1 FROM user_history_hidden_items hhi
@@ -195,7 +200,7 @@ func UserHistoryCTESQL(argIdx int) string {
AND COALESCE(src.uwh_at, src.uwp_at) <= hhi.hidden_before
)
GROUP BY src.media_item_id
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1)
)`, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1, argIdx, argIdx+1)
}
func (qb *QueryBuilder) BuildSortClause(sortConfig QuerySort) (string, []any, error) {
@@ -298,7 +303,8 @@ func (qb *QueryBuilder) BuildSortPlan(sortConfig QuerySort) (QuerySortPlan, erro
return plan, nil
case "series":
plan.Joins = []string{fmt.Sprintf(
"LEFT JOIN audiobook_series sort_series ON sort_series.content_id = %s.content_id",
"LEFT JOIN %s sort_series ON sort_series.content_id = %s.content_id",
qb.bookSeriesTable(),
qb.alias,
)}
// Sort by series name primarily, then by series_index so books
@@ -385,7 +391,7 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) {
case "narrator":
return qb.buildPersonClause(models.PersonKindNarrator, rule)
case "series":
return qb.buildAudiobookSeriesClause(rule)
return qb.buildBookSeriesClause(rule)
case "watched":
return qb.buildWatchedClause(rule)
case "favorited":
@@ -502,14 +508,21 @@ func (qb *QueryBuilder) personKindSortJoin(kind models.PersonKind, alias string)
)
}
func (qb *QueryBuilder) buildAudiobookSeriesClause(rule QueryRule) (string, error) {
func (qb *QueryBuilder) bookSeriesTable() string {
if qb.mediaScope == "ebook" {
return "ebook_series"
}
return "audiobook_series"
}
func (qb *QueryBuilder) buildBookSeriesClause(rule QueryRule) (string, error) {
qb.args = append(qb.args, rule.Value)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM audiobook_series s
FROM %s s
WHERE s.content_id = %s.content_id
AND LOWER(BTRIM(s.series_name)) = LOWER(BTRIM($%d))
)`, qb.alias, qb.argIdx)
)`, qb.bookSeriesTable(), qb.alias, qb.argIdx)
qb.argIdx++
if rule.Op == "is_not" {
return "NOT (" + clause + ")", nil
@@ -947,6 +960,9 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
if !ok {
return "", fmt.Errorf("in_progress requires a boolean value")
}
if qb.mediaScope == "ebook" {
return qb.buildEbookInProgressClause(value), nil
}
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
@@ -972,6 +988,24 @@ func (qb *QueryBuilder) buildInProgressClause(rule QueryRule) (string, error) {
return clause, nil
}
func (qb *QueryBuilder) buildEbookInProgressClause(value bool) string {
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
AND erp.content_id = %s.content_id
AND erp.progress > 0
AND erp.progress < 0.9
)`, qb.argIdx, qb.argIdx+1, qb.alias)
qb.argIdx += 2
if !value {
return "NOT (" + clause + ")"
}
return clause
}
func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) {
if err := qb.ensureUserScope(rule.Field); err != nil {
return "", err
@@ -1070,6 +1104,9 @@ func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) {
}
func (qb *QueryBuilder) userStateCompletionClause() string {
if qb.mediaScope == "ebook" {
return qb.ebookUserStateCompletionClause()
}
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`(
EXISTS (
@@ -1120,6 +1157,20 @@ func (qb *QueryBuilder) userStateCompletionClause() string {
return clause
}
func (qb *QueryBuilder) ebookUserStateCompletionClause() string {
qb.args = append(qb.args, qb.userID, qb.profileID)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
AND erp.content_id = %s.content_id
AND erp.progress >= 0.9
)`, qb.argIdx, qb.argIdx+1, qb.alias)
qb.argIdx += 2
return clause
}
// lastWatchedExpr returns the SQL expression the executor binds to the
// per-user history aggregate. It signals the executor (via
// requireUserHistoryCTE) that the user_last_watched CTE and a LEFT JOIN
@@ -1343,6 +1394,9 @@ func (qb *QueryBuilder) consumeIntArgs(values []int) ([]string, []any) {
}
func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookProgressSortPlan()
}
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT uwp.media_item_id AS content_id,
@@ -1371,7 +1425,31 @@ func (qb *QueryBuilder) progressSortPlan() (string, []string, []any) {
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookProgressSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress > 0
AND erp.progress < 0.9
THEN erp.progress::double precision
ELSE NULL
END AS progress_ratio
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_progress ON sort_progress.content_id = %s.content_id`,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "sort_progress.progress_ratio", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookDateViewedSortPlan()
}
historyJoin := fmt.Sprintf(
`LEFT JOIN (
SELECT uwh.media_item_id AS content_id, MAX(uwh.watched_at) AS viewed_at
@@ -1422,7 +1500,30 @@ func (qb *QueryBuilder) dateViewedSortPlan() (string, []string, []any) {
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookDateViewedSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress >= 0.9
THEN erp.updated_at
ELSE NULL
END AS viewed_at
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_ebook_viewed ON sort_ebook_viewed.content_id = %s.content_id`,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "sort_ebook_viewed.viewed_at", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
if qb.mediaScope == "ebook" {
return qb.ebookPlaysSortPlan()
}
historyJoin := fmt.Sprintf(
`LEFT JOIN (
SELECT uwh.media_item_id AS content_id, COUNT(*) AS play_count
@@ -1473,6 +1574,26 @@ func (qb *QueryBuilder) playsSortPlan() (string, []string, []any) {
return expr, []string{historyJoin, progressJoin}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) ebookPlaysSortPlan() (string, []string, []any) {
joinSQL := fmt.Sprintf(
`LEFT JOIN (
SELECT erp.content_id,
CASE
WHEN erp.progress >= 0.9
THEN 1
ELSE 0
END AS completed_play_count
FROM ebook_reader_progress erp
WHERE erp.user_id = $%d
AND erp.profile_id = $%d
) sort_ebook_plays ON sort_ebook_plays.content_id = %s.content_id`,
qb.argIdx,
qb.argIdx+1,
qb.alias,
)
return "NULLIF(sort_ebook_plays.completed_play_count, 0)", []string{joinSQL}, []any{qb.userID, qb.profileID}
}
func (qb *QueryBuilder) lastAirDateSortPlan() (string, []string) {
expr := fmt.Sprintf("%s.last_air_date_at", qb.alias)
return expr, nil // denormalized column — no JOIN required
+173
View File
@@ -390,6 +390,132 @@ func TestBuildSortClause_PersonalizedComputedSorts(t *testing.T) {
}
}
func TestBuildSortClause_EbookPersonalizedComputedSortsUseReaderProgress(t *testing.T) {
builder := NewQueryBuilder("mi").WithMediaScope("ebook").WithUserScope(42, "profile-1")
tests := []struct {
field string
expectJoin []string
expectExpr []string
}{
{
field: "progress",
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress > 0", "erp.progress < 0.9"},
expectExpr: []string{"sort_progress.progress_ratio"},
},
{
field: "date_viewed",
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9"},
expectExpr: []string{"sort_ebook_viewed.viewed_at"},
},
{
field: "plays",
expectJoin: []string{"FROM ebook_reader_progress erp", "erp.progress >= 0.9", "completed_play_count"},
expectExpr: []string{"sort_ebook_plays.completed_play_count"},
},
}
for _, tt := range tests {
plan, err := builder.BuildSortPlan(QuerySort{Field: tt.field, Order: "desc"})
if err != nil {
t.Fatalf("%s: BuildSortPlan returned error: %v", tt.field, err)
}
if len(plan.Args) != 2 || plan.Args[0] != 42 || plan.Args[1] != "profile-1" {
t.Fatalf("%s: expected user scope args, got %v", tt.field, plan.Args)
}
joined := strings.Join(plan.Joins, "\n")
for _, fragment := range tt.expectJoin {
if !strings.Contains(joined, fragment) {
t.Fatalf("%s: expected joins to contain %q, got %q", tt.field, fragment, joined)
}
}
for _, fragment := range tt.expectExpr {
if !strings.Contains(plan.OrderBy, fragment) {
t.Fatalf("%s: expected order by to contain %q, got %q", tt.field, fragment, plan.OrderBy)
}
}
}
}
func TestBuild_EbookUserStateRulesUseReaderProgress(t *testing.T) {
tests := []struct {
name string
rule QueryRule
expectSQL []string
unexpectSQL []string
}{
{
name: "watched",
rule: QueryRule{Field: "watched", Op: "is", Value: true},
expectSQL: []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
},
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
},
{
name: "unread",
rule: QueryRule{Field: "watched", Op: "is", Value: false},
expectSQL: []string{
"NOT (EXISTS",
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
},
unexpectSQL: []string{"FROM user_watch_progress uwp", "FROM user_watch_history uwh"},
},
{
name: "in progress",
rule: QueryRule{Field: "in_progress", Op: "is", Value: true},
expectSQL: []string{
"FROM ebook_reader_progress erp",
"erp.progress > 0",
"erp.progress < 0.9",
},
unexpectSQL: []string{"FROM user_watch_progress uwp"},
},
{
name: "not in progress",
rule: QueryRule{Field: "in_progress", Op: "is", Value: false},
expectSQL: []string{
"NOT (EXISTS",
"FROM ebook_reader_progress erp",
"erp.progress > 0",
"erp.progress < 0.9",
},
unexpectSQL: []string{"FROM user_watch_progress uwp"},
},
}
for _, tt := range tests {
clause, args, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
WithUserScope(42, "profile-1").
Build(QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{{
Match: "all",
Rules: []QueryRule{tt.rule},
}},
})
if err != nil {
t.Fatalf("%s: Build returned error: %v", tt.name, err)
}
if len(args) != 2 || args[0] != 42 || args[1] != "profile-1" {
t.Fatalf("%s: expected user scope args, got %v", tt.name, args)
}
for _, fragment := range tt.expectSQL {
if !strings.Contains(clause, fragment) {
t.Fatalf("%s: expected clause to contain %q, got %q", tt.name, fragment, clause)
}
}
for _, fragment := range tt.unexpectSQL {
if strings.Contains(clause, fragment) {
t.Fatalf("%s: did not expect clause to contain %q, got %q", tt.name, fragment, clause)
}
}
}
}
func TestBuild_ResolutionNormalizes4KAndScopesMediaFiles(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").
WithLibraryScope([]int{3}).
@@ -531,6 +657,26 @@ func TestBuild_SeriesClauseJoinsAudiobookSeries(t *testing.T) {
}
}
func TestBuild_SeriesClauseJoinsEbookSeriesForEbookScope(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "series", Op: "is", Value: "Wayfarers"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if len(args) != 1 || args[0] != "Wayfarers" {
t.Fatalf("expected series arg, got %v", args)
}
if !strings.Contains(clause, "FROM ebook_series s") {
t.Fatalf("expected ebook_series join in series clause, got %q", clause)
}
}
func TestBuildSortPlan_AuthorJoinsLateralOnPersonKindAuthor(t *testing.T) {
plan, err := NewQueryBuilder("mi").BuildSortPlan(QuerySort{Field: "author", Order: "asc"})
if err != nil {
@@ -576,6 +722,18 @@ func TestBuildSortPlan_SeriesOrdersByNameThenIndex(t *testing.T) {
}
}
func TestBuildSortPlan_SeriesUsesEbookSeriesForEbookScope(t *testing.T) {
plan, err := NewQueryBuilder("mi").
WithMediaScope("ebook").
BuildSortPlan(QuerySort{Field: "series", Order: "asc"})
if err != nil {
t.Fatalf("BuildSortPlan(series) returned error: %v", err)
}
if len(plan.Joins) != 1 || !strings.Contains(plan.Joins[0], "ebook_series sort_series") {
t.Fatalf("expected LEFT JOIN ebook_series, got %v", plan.Joins)
}
}
func TestBuild_SeriesClauseNegationWrapsInNOT(t *testing.T) {
clause, _, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
@@ -713,3 +871,18 @@ func TestQueryBuilder_LastWatched_RequiresCTE(t *testing.T) {
t.Fatalf("after lastWatchedExpr, builder should require CTE")
}
}
func TestUserHistoryCTESQLIncludesCompletedEbookReaderProgress(t *testing.T) {
sql := UserHistoryCTESQL(1)
expectedFragments := []string{
"FROM ebook_reader_progress erp",
"erp.progress >= 0.9",
"erp.content_id",
"erp.updated_at",
}
for _, fragment := range expectedFragments {
if !strings.Contains(sql, fragment) {
t.Fatalf("expected CTE to contain %q, got:\n%s", fragment, sql)
}
}
}
+10 -2
View File
@@ -185,8 +185,10 @@ 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" &&
normalized.MediaScope != "ebook" {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'ebook'")
}
if normalized.Match != "all" && normalized.Match != "any" {
@@ -205,6 +207,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
if def.personalized && !allowPersonalizedFields {
return fmt.Errorf("groups[%d].rules[%d].field %q requires profile scope", i, j, rule.Field)
}
if normalized.MediaScope == "ebook" && rule.Field == "narrator" {
return fmt.Errorf("groups[%d].rules[%d].field %q is not supported for ebook media_scope", i, j, rule.Field)
}
if !def.validOps[rule.Op] {
return fmt.Errorf("groups[%d].rules[%d].op %q is not supported for field %q", i, j, rule.Op, rule.Field)
}
@@ -219,6 +224,9 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
if def.personalized && !allowPersonalizedSorts {
return fmt.Errorf("sort.field %q requires profile scope", normalized.Sort.Field)
}
if normalized.MediaScope == "ebook" && normalized.Sort.Field == "narrator" {
return fmt.Errorf("sort.field %q is not supported for ebook media_scope", normalized.Sort.Field)
}
}
if normalized.Sort.Order != "" && normalized.Sort.Order != "asc" && normalized.Sort.Order != "desc" {
return fmt.Errorf("sort.order must be 'asc' or 'desc'")
+51
View File
@@ -60,6 +60,57 @@ 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 TestValidate_EbookMediaScope(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{},
Sort: QuerySort{Field: "title", Order: "asc"},
}
if err := qd.Validate(); err != nil {
t.Fatalf("expected ebook media scope to be valid, got %v", err)
}
}
func TestValidate_EbookMediaScopeRejectsNarratorRule(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{{
Match: "all",
Rules: []QueryRule{{Field: "narrator", Op: "is", Value: "Narrator"}},
}},
Sort: QuerySort{Field: "title", Order: "asc"},
}
if err := qd.Validate(); err == nil {
t.Fatal("expected ebook narrator rule to be rejected")
}
}
func TestValidate_EbookMediaScopeRejectsNarratorSort(t *testing.T) {
qd := QueryDefinition{
MediaScope: "ebook",
Match: "all",
Groups: []QueryGroup{},
Sort: QuerySort{Field: "narrator", Order: "asc"},
}
if err := qd.Validate(); err == nil {
t.Fatal("expected ebook narrator sort to be rejected")
}
}
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)
}
+642
View File
@@ -0,0 +1,642 @@
package ebooks
// Enricher periodically enriches ebook media_items that are missing metadata
// by querying the configured metadata-provider chain for each item's library
// folder.
import (
"context"
"fmt"
"log/slog"
"os"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
ebookMetadataImageProviderID = "ebook-metadata"
defaultEnrichBatchSize = 50
defaultEnrichWorkers = 4
)
func ebookContentType() string {
return "ebook"
}
func ebookEnrichWorkers() int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_EBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
}
return n
}
type enrichmentItemRow struct {
ContentID string
Title string
Year int
FolderID int
Language string
Author string
ProviderIDs map[string]string
}
// Enricher drives the ebook metadata enrichment sweep.
type Enricher struct {
pool *pgxpool.Pool
chainRepo *metadata.ChainRepository
resolver *metadata.PluginResolverAdapter
itemRepo *catalog.ItemRepository
personRepo *catalog.PersonRepository
providerIDs *catalog.ProviderIDRepository
imageCacher metadata.ImageCacher
batchSize int
workers int
}
func NewEnricher(
pool *pgxpool.Pool,
chainRepo *metadata.ChainRepository,
resolver *metadata.PluginResolverAdapter,
itemRepo *catalog.ItemRepository,
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
return &Enricher{
pool: pool,
chainRepo: chainRepo,
resolver: resolver,
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: ebookEnrichWorkers(),
}
}
func (e *Enricher) SetImageCacher(cacher metadata.ImageCacher) {
if e == nil {
return
}
e.imageCacher = cacher
}
func (e *Enricher) Run(ctx context.Context) (int, error) {
if e == nil || e.pool == nil || e.chainRepo == nil {
return 0, nil
}
items, err := e.claimBatch(ctx)
if err != nil {
return 0, fmt.Errorf("ebook enrichment: claim batch: %w", err)
}
if len(items) == 0 {
return 0, nil
}
slog.Info("ebook enrichment: sweep started",
"count", len(items),
"workers", e.workers,
)
enriched := e.runBatch(ctx, items, e.enrichItem)
slog.Info("ebook enrichment: sweep complete",
"attempted", len(items),
"enriched", enriched,
)
return enriched, nil
}
func (e *Enricher) runBatch(ctx context.Context, items []enrichmentItemRow, enrichFn func(context.Context, enrichmentItemRow) error) int {
workers := e.workers
if workers <= 0 {
workers = 1
}
if workers > len(items) {
workers = len(items)
}
ch := make(chan enrichmentItemRow, workers)
var (
wg sync.WaitGroup
enriched int64
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range ch {
if ctx.Err() != nil {
continue
}
if err := enrichFn(ctx, item); err != nil {
slog.Warn("ebook enrichment: item failed",
"content_id", item.ContentID,
"title", item.Title,
"error", err,
)
continue
}
atomic.AddInt64(&enriched, 1)
}
}()
}
for _, item := range items {
if ctx.Err() != nil {
break
}
ch <- item
}
close(ch)
wg.Wait()
return int(enriched)
}
func (e *Enricher) claimBatch(ctx context.Context) ([]enrichmentItemRow, error) {
rows, err := e.pool.Query(ctx, `
SELECT
mi.content_id,
mi.title,
mi.year,
COALESCE(mil.media_folder_id, 0) AS folder_id,
COALESCE(mf.metadata_language, 'en') AS language,
COALESCE(
(SELECT p.name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = mi.content_id
AND ip.kind = 7
ORDER BY ip.sort_order, ip.id
LIMIT 1),
''
) AS author
FROM media_items mi
LEFT JOIN media_item_libraries mil ON mil.content_id = mi.content_id
LEFT JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mi.type = 'ebook'
AND (mi.poster_path IS NULL OR mi.poster_path = '')
AND mi.last_refreshed IS NULL
ORDER BY mi.created_at ASC
LIMIT $1
`, e.batchSize)
if err != nil {
return nil, fmt.Errorf("querying unenriched ebooks: %w", err)
}
defer rows.Close()
var items []enrichmentItemRow
seen := make(map[string]struct{})
for rows.Next() {
var item enrichmentItemRow
if err := rows.Scan(
&item.ContentID,
&item.Title,
&item.Year,
&item.FolderID,
&item.Language,
&item.Author,
); err != nil {
return nil, fmt.Errorf("scanning ebook enrichment row: %w", err)
}
if _, dup := seen[item.ContentID]; dup {
continue
}
seen[item.ContentID] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating ebook enrichment rows: %w", err)
}
if e.providerIDs != nil {
for i := range items {
pids, err := e.providerIDs.GetByContentID(ctx, items[i].ContentID)
if err == nil {
items[i].ProviderIDs = providerIDMapFromRows(pids)
}
}
}
return items, nil
}
func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error {
if item.FolderID == 0 {
slog.Debug("ebook enrichment: item has no library folder, skipping",
"content_id", item.ContentID,
"title", item.Title,
)
return e.stampLastRefreshed(ctx, item.ContentID)
}
providers, err := metadata.ResolveChain(ctx, item.FolderID, ebookContentType(), e.chainRepo, e.resolver)
if err != nil {
return fmt.Errorf("resolving ebook chain for folder %d: %w", item.FolderID, err)
}
if len(providers) == 0 {
slog.Debug("ebook enrichment: no providers in chain",
"content_id", item.ContentID,
"folder_id", item.FolderID,
)
return e.stampLastRefreshed(ctx, item.ContentID)
}
searchQuery, accumulatedIDs := buildEbookSearchQuery(item)
for _, p := range providers {
sp, ok := p.(metadata.SearchProvider)
if !ok {
continue
}
results, searchErr := sp.Search(ctx, searchQuery)
if searchErr != nil {
slog.Warn("ebook enrichment: search error",
"provider", p.Slug(),
"content_id", item.ContentID,
"error", searchErr,
)
continue
}
if len(results) == 0 {
continue
}
for k, v := range results[0].ProviderIDs {
if v != "" {
if _, exists := accumulatedIDs[k]; !exists {
accumulatedIDs[k] = v
}
}
}
slog.Debug("ebook enrichment: search result",
"provider", p.Slug(),
"content_id", item.ContentID,
"matched_ids", accumulatedIDs,
)
}
accumulator := &metadata.MetadataResult{
ProviderIDs: accumulatedIDs,
}
for _, p := range providers {
mp, ok := p.(metadata.MetadataProvider)
if !ok {
continue
}
result, getErr := mp.GetMetadata(ctx, buildEbookMetadataRequest(accumulatedIDs, item.Language))
if getErr != nil {
slog.Warn("ebook enrichment: GetMetadata error",
"provider", p.Slug(),
"content_id", item.ContentID,
"error", getErr,
)
continue
}
if result == nil || !result.HasMetadata {
continue
}
mergeEnrichmentProviderIDs(accumulator, result)
accumulatedIDs = accumulator.ProviderIDs
metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty)
slog.Debug("ebook enrichment: metadata received",
"provider", p.Slug(),
"content_id", item.ContentID,
"has_poster", result.PosterPath != "",
"has_overview", result.Overview != "",
)
}
if !accumulator.HasMetadata && accumulator.PosterPath == "" && accumulator.Overview == "" {
slog.Info("ebook enrichment: no metadata found",
"content_id", item.ContentID,
"title", item.Title,
)
return e.stampLastRefreshed(ctx, item.ContentID)
}
e.cacheRemotePoster(ctx, item.ContentID, accumulator)
if err := e.persist(ctx, item.ContentID, accumulatedIDs, accumulator); err != nil {
return fmt.Errorf("persisting enrichment for %s: %w", item.ContentID, err)
}
slog.Info("ebook enrichment: enriched",
"content_id", item.ContentID,
"title", item.Title,
"poster", accumulator.PosterPath != "",
"overview", accumulator.Overview != "",
"people", len(filterEbookPeople(accumulator.People)),
)
return nil
}
func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, result *metadata.MetadataResult) {
if e == nil || result == nil || result.PosterPath == "" {
return
}
if !strings.HasPrefix(result.PosterPath, "http://") && !strings.HasPrefix(result.PosterPath, "https://") {
return
}
if isNilImageCacher(e.imageCacher) {
return
}
cached, err := e.imageCacher.CacheImage(ctx, metadata.CacheImageRequest{
SourceURL: result.PosterPath,
ProviderID: ebookMetadataImageProviderID,
ContentType: "ebooks",
ContentID: contentID,
ImageType: metadata.ImagePoster,
})
if err != nil {
slog.Warn("ebook enrichment: poster cache failed, keeping provider URL",
"content_id", contentID,
"url", result.PosterPath,
"error", err,
)
return
}
if cached == nil {
slog.Warn("ebook enrichment: poster cache returned no result, keeping provider URL",
"content_id", contentID,
"url", result.PosterPath,
)
return
}
if storedPath := cachedOriginalImagePath(cached.BasePath, cached.Ext); storedPath != "" {
result.PosterPath = storedPath
}
if cached.Thumbhash != "" {
result.PosterThumbhash = cached.Thumbhash
}
}
func isNilImageCacher(cacher metadata.ImageCacher) bool {
if cacher == nil {
return true
}
value := reflect.ValueOf(cacher)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func cachedOriginalImagePath(basePath, ext string) string {
if basePath == "" {
return ""
}
if strings.Contains(basePath, "/original.") {
return basePath
}
if ext == "" {
ext = ".jpg"
}
return strings.TrimRight(basePath, "/") + "/original" + ext
}
func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs map[string]string, result *metadata.MetadataResult) error {
upd := &catalog.MetadataUpdate{}
if result.PosterPath != "" {
upd.PosterPath = &result.PosterPath
}
if result.PosterThumbhash != "" {
upd.PosterThumbhash = &result.PosterThumbhash
}
if result.BackdropPath != "" {
upd.BackdropPath = &result.BackdropPath
}
if result.BackdropThumbhash != "" {
upd.BackdropThumbhash = &result.BackdropThumbhash
}
if result.LogoPath != "" {
upd.LogoPath = &result.LogoPath
}
if result.Overview != "" {
upd.Overview = &result.Overview
}
if result.Tagline != "" {
upd.Tagline = &result.Tagline
}
if result.ReleaseDate != "" {
upd.ReleaseDate = &result.ReleaseDate
}
if len(result.Genres) > 0 {
genres := append([]string(nil), result.Genres...)
upd.Genres = &genres
}
if len(result.Studios) > 0 {
studios := append([]string(nil), result.Studios...)
upd.Studios = &studios
}
if result.ContentRating != "" {
upd.ContentRating = &result.ContentRating
}
if result.Runtime > 0 {
upd.Runtime = &result.Runtime
}
if result.Year > 0 {
upd.Year = &result.Year
}
providerIDs = filterEbookProviderIDs(providerIDs)
if e.providerIDs != nil && len(providerIDs) > 0 {
if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
slog.Warn("ebook enrichment: failed to persist provider IDs",
"content_id", contentID,
"error", err,
)
}
}
if err := e.updateMetadataAndTimestamps(ctx, contentID, upd); err != nil {
return err
}
authors := filterEbookPeople(result.People)
if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil {
if err := e.persistPeople(ctx, contentID, authors); err != nil {
slog.Warn("ebook enrichment: failed to persist people",
"content_id", contentID,
"error", err,
)
}
}
return nil
}
func (e *Enricher) updateMetadataAndTimestamps(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error {
if e.itemRepo == nil {
return nil
}
if err := e.itemRepo.UpdateMetadata(ctx, contentID, upd); err != nil {
return fmt.Errorf("UpdateMetadata: %w", err)
}
return e.stampLastRefreshed(ctx, contentID)
}
func (e *Enricher) stampLastRefreshed(ctx context.Context, contentID string) error {
if e.pool == nil {
return nil
}
now := time.Now().UTC()
_, err := e.pool.Exec(ctx, `
UPDATE media_items
SET last_refreshed = $1,
matched_at = COALESCE(matched_at, $1),
status = CASE WHEN status = 'pending' THEN 'matched' ELSE status END
WHERE content_id = $2
`, now, contentID)
return err
}
func (e *Enricher) persistPeople(ctx context.Context, contentID string, people []models.ItemPerson) error {
people = filterEbookPeople(people)
if len(people) == 0 {
return nil
}
persons := make([]models.Person, len(people))
for i := range people {
persons[i] = people[i].Person
}
personIDs, err := e.personRepo.BatchFindOrCreate(ctx, persons)
if err != nil {
return fmt.Errorf("BatchFindOrCreate people: %w", err)
}
linked := make([]models.ItemPerson, 0, len(people))
for i := range people {
if i >= len(personIDs) || personIDs[i] == 0 {
continue
}
ip := people[i]
ip.Person.ID = personIDs[i]
linked = append(linked, ip)
}
if len(linked) == 0 {
return nil
}
return e.itemRepo.ReplacePeople(ctx, contentID, linked)
}
func filterEbookPeople(people []models.ItemPerson) []models.ItemPerson {
authors := make([]models.ItemPerson, 0, len(people))
for _, person := range people {
if person.Kind != models.PersonKindAuthor {
continue
}
person.SortOrder = len(authors)
authors = append(authors, person)
}
return authors
}
func buildEbookSearchQuery(item enrichmentItemRow) (metadata.SearchQuery, map[string]string) {
accumulatedIDs := filterEbookProviderIDs(item.ProviderIDs)
if accumulatedIDs == nil {
accumulatedIDs = map[string]string{}
}
return metadata.SearchQuery{
Title: item.Title,
Year: item.Year,
ContentType: ebookContentType(),
ProviderIDs: accumulatedIDs,
Language: item.Language,
}, accumulatedIDs
}
func buildEbookMetadataRequest(providerIDs map[string]string, language string) metadata.MetadataRequest {
return metadata.MetadataRequest{
ProviderIDs: filterEbookProviderIDs(providerIDs),
ContentType: ebookContentType(),
Language: language,
}
}
func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) {
if src == nil || len(src.ProviderIDs) == 0 {
return
}
if dst.ProviderIDs == nil {
dst.ProviderIDs = make(map[string]string, len(src.ProviderIDs))
}
for k, v := range filterEbookProviderIDs(src.ProviderIDs) {
if v != "" {
if _, exists := dst.ProviderIDs[k]; !exists {
dst.ProviderIDs[k] = v
}
}
}
}
func filterEbookProviderIDs(providerIDs map[string]string) map[string]string {
if len(providerIDs) == 0 {
return nil
}
filtered := make(map[string]string, len(providerIDs))
for provider, providerID := range providerIDs {
provider = strings.TrimSpace(provider)
providerID = strings.TrimSpace(providerID)
if provider == "" || providerID == "" {
continue
}
provider = strings.ToLower(provider)
if isEbookASINProvider(provider) {
continue
}
filtered[provider] = providerID
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func isEbookASINProvider(provider string) bool {
normalized := strings.ReplaceAll(strings.ReplaceAll(provider, "_", ""), "-", "")
return normalized == "asin" || normalized == "audibleasin"
}
func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string {
if len(rows) == 0 {
return nil
}
m := make(map[string]string, len(rows))
for _, r := range rows {
if r != nil {
for provider, providerID := range filterEbookProviderIDs(map[string]string{
r.Provider: r.ProviderID,
}) {
m[provider] = providerID
}
}
}
return m
}
+340
View File
@@ -0,0 +1,340 @@
package ebooks
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestEbookContentType(t *testing.T) {
if got := ebookContentType(); got != "ebook" {
t.Fatalf("ebookContentType() = %q, want ebook", got)
}
}
func TestFilterEbookPeopleKeepsAuthorsOnly(t *testing.T) {
people := []models.ItemPerson{
{Person: models.Person{Name: "Author One"}, Kind: models.PersonKindAuthor, SortOrder: 7},
{Person: models.Person{Name: "Narrator One"}, Kind: models.PersonKindNarrator, SortOrder: 8},
{Person: models.Person{Name: "Writer One"}, Kind: models.PersonKindWriter, SortOrder: 9},
{Person: models.Person{Name: "Author Two"}, Kind: models.PersonKindAuthor, SortOrder: 10},
}
got := filterEbookPeople(people)
if len(got) != 2 {
t.Fatalf("filtered people len = %d, want 2: %+v", len(got), got)
}
for i, p := range got {
if p.Kind != models.PersonKindAuthor {
t.Fatalf("filtered[%d].Kind = %v, want author", i, p.Kind)
}
if p.SortOrder != i {
t.Fatalf("filtered[%d].SortOrder = %d, want %d", i, p.SortOrder, i)
}
}
if got[0].Person.Name != "Author One" || got[1].Person.Name != "Author Two" {
t.Fatalf("filtered author order = %+v", got)
}
}
func TestEbookEnrichWorkersFromEnv(t *testing.T) {
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "12")
if got := ebookEnrichWorkers(); got != 12 {
t.Fatalf("ebookEnrichWorkers() = %d, want 12", got)
}
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "0")
if got := ebookEnrichWorkers(); got != defaultEnrichWorkers {
t.Fatalf("ebookEnrichWorkers() with zero = %d, want default %d", got, defaultEnrichWorkers)
}
t.Setenv("SILO_EBOOK_ENRICH_WORKERS", "999")
if got := ebookEnrichWorkers(); got != defaultEnrichBatchSize {
t.Fatalf("ebookEnrichWorkers() capped = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestEnricherRunFansOut(t *testing.T) {
const wantWorkers = 4
const itemCount = 16
items := make([]enrichmentItemRow, itemCount)
for i := range items {
items[i] = enrichmentItemRow{ContentID: "test", Title: "t"}
}
var inFlight int32
var maxInFlight int32
var signaled int32
var wg sync.WaitGroup
wg.Add(wantWorkers)
gate := make(chan struct{})
enrich := func(ctx context.Context, item enrichmentItemRow) error {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
prev := atomic.LoadInt32(&maxInFlight)
if cur <= prev || atomic.CompareAndSwapInt32(&maxInFlight, prev, cur) {
break
}
}
if atomic.AddInt32(&signaled, 1) <= wantWorkers {
wg.Done()
}
select {
case <-gate:
case <-time.After(2 * time.Second):
}
return nil
}
go func() {
wg.Wait()
close(gate)
}()
e := &Enricher{workers: wantWorkers, batchSize: itemCount}
e.runBatch(context.Background(), items, enrich)
if got := atomic.LoadInt32(&maxInFlight); got < wantWorkers {
t.Errorf("max in-flight = %d, want >= %d", got, wantWorkers)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeEbookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if cacher.req.ProviderID != ebookMetadataImageProviderID {
t.Fatalf("ProviderID = %q, want %q", cacher.req.ProviderID, ebookMetadataImageProviderID)
}
if cacher.req.ContentType != "ebooks" || cacher.req.ContentID != "content-1" {
t.Fatalf("cache target = %q/%q", cacher.req.ContentType, cacher.req.ContentID)
}
if result.PosterPath != "ebook-metadata/ebooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
if result.PosterThumbhash != "thumb" {
t.Fatalf("PosterThumbhash = %q", result.PosterThumbhash)
}
}
func TestCacheRemotePosterSkipsNilCacher(t *testing.T) {
e := &Enricher{}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterSkipsTypedNilCacher(t *testing.T) {
var cacher *fakeEbookImageCacher
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterSkipsAlreadyCachedPath(t *testing.T) {
cacher := &fakeEbookImageCacher{}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "local/ebooks/content-1/poster/original.webp",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 0 {
t.Fatalf("CacheImage calls = %d, want 0", cacher.calls)
}
if result.PosterPath != "local/ebooks/content-1/poster/original.webp" {
t.Fatalf("PosterPath = %q", result.PosterPath)
}
}
func TestCacheRemotePosterPreservesProviderURLOnCacheError(t *testing.T) {
cacher := &fakeEbookImageCacher{err: errors.New("cache failed")}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestCacheRemotePosterPreservesProviderURLOnNilCacheResult(t *testing.T) {
cacher := &fakeEbookImageCacher{returnNil: true}
e := &Enricher{imageCacher: cacher}
result := &metadata.MetadataResult{
PosterPath: "https://example.test/book.jpg",
}
e.cacheRemotePoster(context.Background(), "content-1", result)
if cacher.calls != 1 {
t.Fatalf("CacheImage calls = %d, want 1", cacher.calls)
}
if result.PosterPath != "https://example.test/book.jpg" {
t.Fatalf("PosterPath = %q, want provider URL preserved", result.PosterPath)
}
}
func TestMergeEnrichmentProviderIDsKeepsExistingIDs(t *testing.T) {
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
src := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "new", "openlibrary": "OL1M", "empty": ""}}
mergeEnrichmentProviderIDs(dst, src)
if got := dst.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("isbn = %q, want original", got)
}
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("openlibrary = %q, want OL1M", got)
}
if _, exists := dst.ProviderIDs["empty"]; exists {
t.Fatal("empty provider ID should not be merged")
}
}
func TestMergeEnrichmentProviderIDsDropsAsinIDs(t *testing.T) {
dst := &metadata.MetadataResult{ProviderIDs: map[string]string{"isbn": "9780306406157"}}
src := &metadata.MetadataResult{
ProviderIDs: map[string]string{
"ASIN": "B00TEST",
"audible_asin": "B00AUDIO",
"openlibrary": "OL1M",
},
}
mergeEnrichmentProviderIDs(dst, src)
if _, exists := dst.ProviderIDs["ASIN"]; exists {
t.Fatal("ASIN provider ID should not be merged for ebooks")
}
if _, exists := dst.ProviderIDs["audible_asin"]; exists {
t.Fatal("audible_asin provider ID should not be merged for ebooks")
}
if got := dst.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("openlibrary = %q, want OL1M", got)
}
}
func TestBuildEbookSearchQueryUsesFilteredScannerISBN(t *testing.T) {
item := enrichmentItemRow{
Title: "Tagged Ebook",
Year: 2024,
Language: "en",
ProviderIDs: map[string]string{
" ISBN ": " 9780306406157 ",
"ASIN": "B00TEST",
"audible_asin": "B00AUDIO",
},
}
query, ids := buildEbookSearchQuery(item)
if query.Title != "Tagged Ebook" || query.Year != 2024 || query.Language != "en" || query.ContentType != "ebook" {
t.Fatalf("query basics = %+v", query)
}
if got := query.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("query isbn = %q, want scanner ISBN", got)
}
if _, exists := query.ProviderIDs["ASIN"]; exists {
t.Fatal("query should not include ASIN for ebooks")
}
if got := ids["isbn"]; got != "9780306406157" {
t.Fatalf("accumulated isbn = %q, want scanner ISBN", got)
}
}
func TestFilterEbookProviderIDsDropsAsinAliases(t *testing.T) {
got := filterEbookProviderIDs(map[string]string{
"ASIN": "B00TEST",
"audibleASIN": "B00AUDIO",
"audible-asin": "B00AUDIO2",
"audible_asin": "B00AUDIO3",
" ISBN ": " 9780306406157 ",
" OpenLibraryID": " OL1M ",
})
if got["asin"] != "" || got["audibleasin"] != "" || got["audible-asin"] != "" || got["audible_asin"] != "" {
t.Fatalf("ASIN aliases should be filtered, got %+v", got)
}
if got["isbn"] != "9780306406157" || got["openlibraryid"] != "OL1M" {
t.Fatalf("filtered IDs = %+v, want ISBN and OpenLibraryID only", got)
}
}
func TestBuildEbookMetadataRequestCarriesAccumulatedISBN(t *testing.T) {
req := buildEbookMetadataRequest(map[string]string{
" ISBN ": " 9780306406157 ",
"openlibrary": "OL1M",
}, "fr")
if req.ContentType != "ebook" || req.Language != "fr" {
t.Fatalf("request basics = %+v", req)
}
if got := req.ProviderIDs["isbn"]; got != "9780306406157" {
t.Fatalf("request isbn = %q, want normalized key/value", got)
}
if got := req.ProviderIDs["openlibrary"]; got != "OL1M" {
t.Fatalf("request openlibrary = %q, want OL1M", got)
}
}
type fakeEbookImageCacher struct {
calls int
req metadata.CacheImageRequest
err error
returnNil bool
}
func (f *fakeEbookImageCacher) CacheImage(_ context.Context, req metadata.CacheImageRequest) (*metadata.CacheImageResult, error) {
f.calls++
f.req = req
if f.err != nil {
return nil, f.err
}
if f.returnNil {
return nil, nil
}
return &metadata.CacheImageResult{
BasePath: req.ProviderID + "/" + req.ContentType + "/" + req.ContentID + "/poster",
Thumbhash: "thumb",
Ext: ".webp",
}, nil
}
+16
View File
@@ -114,6 +114,22 @@ func (c *Cacher) CacheAudiobookCover(ctx context.Context, data []byte, contentID
return res.BasePath, res.Ext, res.Thumbhash, nil
}
// CacheEbookCover stores an embedded ebook cover under
// "local/ebooks/{contentID}/poster/..." using the same poster variants as
// provider-hosted book artwork.
func (c *Cacher) CacheEbookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error) {
res, err := c.CacheBytes(ctx, data, CacheRequest{
ProviderID: "local",
ContentType: "ebooks",
ContentID: contentID,
ImageType: metadata.ImagePoster,
})
if err != nil {
return "", "", "", err
}
return res.BasePath, res.Ext, res.Thumbhash, nil
}
// CacheBytes performs the same variant generation, thumbhash, and S3 upload as
// Cache but starts from raw image bytes already in hand. Used by the
// audiobook scanner to push embedded M4B cover art into S3 without round-
+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)
}
@@ -722,6 +722,19 @@ var mediaItemMergeSteps = []mediaItemMergeStep{
last_codec_video = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_codec_video ELSE user_watch_progress.last_codec_video END,
last_edition_key = CASE WHEN EXCLUDED.updated_at >= user_watch_progress.updated_at THEN EXCLUDED.last_edition_key ELSE user_watch_progress.last_edition_key END`},
{"delete source progress", `DELETE FROM user_watch_progress WHERE media_item_id = $1`},
{"merge ebook reader progress", `
INSERT INTO ebook_reader_progress (
user_id, profile_id, content_id, file_id, location, progress, updated_at
)
SELECT user_id, profile_id, $2, file_id, location, progress, updated_at
FROM ebook_reader_progress
WHERE content_id = $1
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE
SET file_id = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.file_id ELSE ebook_reader_progress.file_id END,
location = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.location ELSE ebook_reader_progress.location END,
progress = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.progress ELSE ebook_reader_progress.progress END,
updated_at = GREATEST(ebook_reader_progress.updated_at, EXCLUDED.updated_at)`},
{"delete source ebook reader progress", `DELETE FROM ebook_reader_progress WHERE content_id = $1`},
{"move history", `UPDATE user_watch_history SET media_item_id = $2 WHERE media_item_id = $1`},
{"merge hidden history", `
INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at)
@@ -110,6 +110,25 @@ func TestMergeStepPlaceholdersAreBounded(t *testing.T) {
}
}
func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
var hasMerge bool
var hasDelete bool
for _, step := range mediaItemMergeSteps {
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "INSERT INTO") {
hasMerge = true
}
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "DELETE FROM") {
hasDelete = true
}
}
if !hasMerge {
t.Fatal("media item merge steps should merge ebook_reader_progress rows")
}
if !hasDelete {
t.Fatal("media item merge steps should delete source ebook_reader_progress rows")
}
}
func TestBothCandidatesHaveContentDetectsRealUserData(t *testing.T) {
withFiles := canonicalCandidate{ContentID: "a", ActiveFiles: 2}
withLibraries := canonicalCandidate{ContentID: "b", LibraryCount: 1}
+98 -14
View File
@@ -70,6 +70,10 @@ type metadataItemRepo interface {
ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error)
}
type metadataItemDeleteRepo interface {
Delete(ctx context.Context, contentID string) ([]string, error)
}
type metadataProviderIDRepo interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaItemProviderID, error)
ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error
@@ -3741,18 +3745,9 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
return s.hooks.createOrFindSkeleton(ctx, file, folderID)
}
libraryType := ""
if s != nil && s.folderRepo != nil && folderID > 0 {
folder, err := s.folderRepo.GetByID(ctx, folderID)
if err != nil {
slog.Warn("metadata: failed to load folder context for path classification",
"folder_id", folderID,
"file_path", file.FilePath,
"error", err,
)
} else if folder != nil {
libraryType = folder.Type
}
libraryType, err := s.folderTypeForSkeleton(ctx, folderID)
if err != nil {
return nil, err
}
contentRootPath := filepath.Dir(file.FilePath)
@@ -4027,7 +4022,12 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
// pre-confirmation ownership claims. Matching is driven by explicit IDs or
// later provider confirmation to avoid cross-root false merges.
// No existing item — create skeleton.
// No existing item — create skeleton. Re-check the folder state while still
// under the dedup lock so a library disabled for deletion cannot receive new
// skeleton items from a matcher that passed an older outer enabled check.
if _, err := s.folderTypeForSkeleton(ctx, folderID); err != nil {
return nil, err
}
contentID, err := generateContentID()
if err != nil {
return nil, fmt.Errorf("generate content id: %w", err)
@@ -4054,10 +4054,53 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
// Link file, claim root, and create library membership.
if err := s.fileRepo.UpdateContentID(ctx, file.ID, contentID); err != nil {
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
slog.Warn("metadata: failed to clean up unlinked skeleton",
"content_id", contentID,
"file_id", file.ID,
"folder_id", folderID,
"error", cleanupErr,
)
}
return nil, fmt.Errorf("linking file to skeleton: %w", err)
}
if _, err := s.folderTypeForSkeleton(ctx, folderID); err != nil {
if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil {
slog.Warn("metadata: failed to clear file link after disabled skeleton folder",
"content_id", contentID,
"file_id", file.ID,
"folder_id", folderID,
"error", clearErr,
)
}
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
slog.Warn("metadata: failed to clean up skeleton for disabled folder",
"content_id", contentID,
"file_id", file.ID,
"folder_id", folderID,
"error", cleanupErr,
)
}
return nil, fmt.Errorf("validating skeleton folder before membership: %w", err)
}
if err := s.upsertLibraryMembership(ctx, contentID, folderID); err != nil {
s.logLibraryMembershipError("upserting skeleton membership", contentID, folderID, err)
if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil {
slog.Warn("metadata: failed to clear file link after skeleton membership failure",
"content_id", contentID,
"file_id", file.ID,
"folder_id", folderID,
"error", clearErr,
)
}
if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil {
slog.Warn("metadata: failed to clean up membershipless skeleton",
"content_id", contentID,
"file_id", file.ID,
"folder_id", folderID,
"error", cleanupErr,
)
}
return nil, fmt.Errorf("upserting skeleton membership: %w", err)
}
res.ContentID = contentID
@@ -4065,6 +4108,47 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
return res, nil
}
func (s *MetadataService) folderTypeForSkeleton(ctx context.Context, folderID int) (string, error) {
if s == nil || s.folderRepo == nil || folderID <= 0 {
return "", nil
}
folder, err := s.folderRepo.GetByID(ctx, folderID)
if err != nil {
return "", fmt.Errorf("loading folder context for skeleton: %w", err)
}
if folder == nil {
return "", fmt.Errorf("folder %d is unavailable for skeleton creation", folderID)
}
if !folder.Enabled {
return "", fmt.Errorf("folder %d is disabled for skeleton creation", folderID)
}
return folder.Type, nil
}
func (s *MetadataService) deleteCreatedSkeleton(ctx context.Context, contentID string) error {
if s == nil || strings.TrimSpace(contentID) == "" {
return nil
}
if repo, ok := s.itemRepo.(metadataItemDeleteRepo); ok {
if _, err := repo.Delete(ctx, contentID); err != nil && !errors.Is(err, catalog.ErrItemNotFound) {
return err
}
return nil
}
if s.dbPool == nil {
return nil
}
_, err := s.dbPool.Exec(ctx, `
DELETE FROM media_items
WHERE content_id = $1
AND NOT EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = media_items.content_id
)`, contentID)
return err
}
// claimRoot records a canonical root path claim for dedup. Failures are logged
// but not fatal — the root claim is an optimization, not a hard requirement.
func (s *MetadataService) claimRoot(ctx context.Context, folderID int, rootPath, contentID string) {
+126 -1
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -81,6 +82,16 @@ func (r *fakeItemRepo) Upsert(_ context.Context, item *models.MediaItem) error {
return nil
}
func (r *fakeItemRepo) Delete(_ context.Context, contentID string) ([]string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.items[contentID]; !ok {
return nil, catalog.ErrItemNotFound
}
delete(r.items, contentID)
return nil, nil
}
func (r *fakeItemRepo) IncrementRefreshFailure(_ context.Context, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -259,6 +270,8 @@ type fakeFileRepo struct {
groupContent map[string]string // "folderID:version:key" -> contentID
groupFiles map[string][]*models.MediaFile
matchStamps map[int]time.Time // fileID -> match_attempted_at
updateErrors map[int]error
afterUpdate func(fileID int, contentID string)
claimUnmatchedCalls int
claimNonSeriesCalls int
claimMixedCalls int
@@ -273,13 +286,22 @@ func newFakeFileRepo() *fakeFileRepo {
groupContent: make(map[string]string),
groupFiles: make(map[string][]*models.MediaFile),
matchStamps: make(map[int]time.Time),
updateErrors: make(map[int]error),
}
}
func (r *fakeFileRepo) UpdateContentID(_ context.Context, fileID int, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.updateErrors[fileID]; err != nil {
r.mu.Unlock()
return err
}
r.contentIDs[fileID] = contentID
afterUpdate := r.afterUpdate
r.mu.Unlock()
if afterUpdate != nil {
afterUpdate(fileID, contentID)
}
return nil
}
@@ -1168,6 +1190,109 @@ func TestCreateOrFindSkeleton_NoFolderIDs(t *testing.T) {
}
}
func TestCreateOrFindSkeleton_DisabledFolderDoesNotCreateItem(t *testing.T) {
h := newTestHarness()
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: false},
},
}
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err == nil {
t.Fatal("expected disabled folder error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("expected disabled error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected no items to be created, got %d", len(h.itemRepo.items))
}
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
t.Fatalf("expected file to remain unlinked, got content_id %q", cid)
}
}
func TestCreateOrFindSkeleton_LinkFailureDeletesSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
linkErr := errors.New("media file not found")
h.fileRepo.updateErrors[1] = linkErr
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err == nil {
t.Fatal("expected link error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !errors.Is(err, linkErr) {
t.Fatalf("expected wrapped link error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
}
if len(h.libraryRepo.memberships) != 0 {
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
}
}
func TestCreateOrFindSkeleton_DisabledBeforeMembershipDeletesSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
folder := &models.MediaFolder{ID: 10, Type: "movies", Enabled: true}
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{10: folder},
}
h.fileRepo.afterUpdate = func(_ int, contentID string) {
if contentID != "" {
folder.Enabled = false
}
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err == nil {
t.Fatal("expected disabled folder error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("expected disabled error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
}
if len(h.libraryRepo.memberships) != 0 {
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
}
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
t.Fatalf("expected file link to be cleared, got content_id %q", cid)
}
}
// TestCreateOrFindSkeleton_PendingItemGetsLibraryMembership verifies that
// library membership is created immediately for pending items, not just for
// matched items.
+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=640x360: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 {

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