Compare commits

...
Author SHA1 Message Date
RXWatcher 69c5e6d1a1 chore(audiobooks): adopt goose migration layout 2026-06-07 09:48:35 +02:00
RXWatcher 0cd37aa618 fix(audiobooks): propagate scanner cancellation 2026-06-07 09:44:34 +02:00
RXWatcher 4d894379df fix(audiobooks): tighten scanner identity handling 2026-06-07 09:44:34 +02:00
RXWatcher 44fc0090c7 fix(audiobooks): address foundation review comments 2026-06-07 09:44:34 +02:00
RXWatcherandClaude Opus 4.7 38c7fb44e1 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>
2026-06-07 09:44:21 +02:00
RXWatcherandClaude Opus 4.7 c0222a55e1 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>
2026-06-07 09:44:21 +02:00
RXWatcherandClaude Opus 4.7 ea6636f9d9 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>
2026-06-07 09:44:21 +02:00
RXWatcherandClaude Opus 4.7 8c172ec9dd 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>
2026-06-07 09:44:21 +02:00
RXWatcherandClaude Opus 4.7 514b72cddb 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>
2026-06-07 09:44:21 +02:00
RXWatcherandClaude Opus 4.7 6983925b2e docs(audiobooks): implementation plan sub-plan 5 (podcasts)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 d897124e94 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 543534a058 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 234491f1cc 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 cb0cb04534 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 d4dc695e1d 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 f4b9d4cf38 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 fd41f56381 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>
2026-06-07 09:43:35 +02:00
RXWatcherandClaude Opus 4.7 aef670c3bc 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 3a43537e67 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 bc2b815391 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 7af0cf241e 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 209fb50f16 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 a5a0fad86e 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 9c6964e1a1 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 901285b225 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 620cd2746b 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 355da1a4fd 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 8deb18273d 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 a8227723bf 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 28ef3ac3c8 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>
2026-06-07 09:43:34 +02:00
RXWatcherandClaude Opus 4.7 6d77e5a135 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>
2026-06-07 09:43:34 +02:00
36 changed files with 4288 additions and 16 deletions
+18
View File
@@ -32,6 +32,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/audiobooks"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/api/handlers"
"github.com/Silo-Server/silo-server/internal/auth"
@@ -1503,6 +1504,12 @@ func main() {
slog.Info("task manager started")
}
// Scaffold audiobooks service (sub-plan 1: kill-switch reader only).
// audiobooksSettingsAdapter (defined at package level below) bridges
// catalog.ServerSettingsRepo.Get to audiobooks.SettingsReader.GetString.
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
_ = audiobooksService // sub-plan 2+ will register routes / scheduled tasks
if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil {
userRepo := auth.NewUserRepository(deps.DB)
sessionRepo := auth.NewSessionRepository(deps.DB)
@@ -2264,3 +2271,14 @@ func mapFolderTypeToMediaType(t string) string {
return "mixed"
}
}
// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which
// exposes Get) to the audiobooks.SettingsReader interface (which
// requires GetString). The two signatures are identical modulo name.
type audiobooksSettingsAdapter struct {
repo *catalog.ServerSettingsRepo
}
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
return a.repo.Get(ctx, key)
}
@@ -0,0 +1,916 @@
# Audiobooks Absorption — Sub-plan 1: Discovery + Schema
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve open data-model questions from the audiobooks spec, then land the foundational schema (two new tables, optional one column add, one feature-flag setting) and a compiling-but-empty `internal/audiobooks/` package scaffold. No user-visible behavior change after this sub-plan; silo continues to operate exactly as it does today.
**Architecture:** Six tasks total. Task 1 audits silo-server's existing schema/code and writes a findings doc that locks the remaining decisions. Tasks 2–5 land additive SQL migrations (idempotent up + symmetric down). Task 6 creates a Go package skeleton with one no-op service constructor wired into the dependency-injection point in `cmd/silo`, which proves the new package compiles inside the rest of the binary without changing any runtime behavior.
**Tech Stack:** Go 1.26 + pgx; PostgreSQL 18 (pgvector image); plain `.up.sql` / `.down.sql` migrations applied at startup by `internal/database/migrate.go`. Frontend out of scope for this sub-plan.
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
---
## File Structure
| Path | Created/Modified | Purpose |
|---|---|---|
| `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md` | Create | Findings doc produced by Task 1. Locks the remaining schema/code decisions. |
| `migrations/147_abs_sessions.up.sql` | Create | New table parallel to `jellycompat_sessions`. |
| `migrations/147_abs_sessions.down.sql` | Create | Drop the table. |
| `migrations/157_podcast_feeds.up.sql` | Create | New table for RSS-subscribed podcast metadata. |
| `migrations/157_podcast_feeds.down.sql` | Create | Drop the table. |
| `migrations/159_media_folders_kind_noop.up.sql` | Create (conditional — see Task 4 step 1) | Document `media_folders.type='audiobooks'`; no column change needed. |
| `migrations/159_media_folders_kind_noop.down.sql` | Create (same condition) | No-op rollback. |
| `migrations/160_audiobooks_feature_flag.up.sql` | Create | Insert `audiobooks.enabled='false'` into `server_settings`. |
| `migrations/160_audiobooks_feature_flag.down.sql` | Create | Delete the setting row. |
| `internal/audiobooks/doc.go` | Create | Package comment + intent. |
| `internal/audiobooks/service.go` | Create | Empty `Service` struct, `New` constructor, `Enabled()` reader. |
| `internal/audiobooks/service_test.go` | Create | One unit test proving the package compiles and `Enabled()` returns the setting value. |
| `cmd/silo/main.go` | Modify | Construct `audiobooks.New(...)` once during startup so the scaffold is referenced. No routes are mounted, no tasks registered. |
Migrations and the scaffold compile and run independently from the audiobooks ports that arrive in later sub-plans.
---
## Task 1: Discovery Audit
Audits silo-server's existing schema and code to resolve the spec's open
Risk questions. Produces one findings document and one commit. No code
changes elsewhere.
**Files:**
- Create: `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md`
### Step 1.1: Create the findings doc skeleton
- [ ] **Create** `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md` with this exact content:
```markdown
# Audiobooks Absorption — Discovery Findings
Produced by sub-plan 1, Task 1. Locks data-model and integration
decisions for migrations 139–142 and downstream sub-plans.
## D1 — Next migration number
(Filled in step 1.2)
## D2 — `media_libraries` kind/type column
(Filled in step 1.3)
## D3 — `media_files.chapters` JSONB shape
(Filled in step 1.4)
## D4 — `user_watch_progress` scoping (profile vs user)
(Filled in step 1.5)
## D5 — `user_playback_sessions` audiobook fit
(Filled in step 1.6)
## D6 — `people` / `item_people` role conventions
(Filled in step 1.7)
## D7 — Catalog FTS handling of `type='audiobook'`
(Filled in step 1.8)
## D8 — First-party scheduled-task registration
(Filled in step 1.9)
```
### Step 1.2: D1 — Migration number
- [ ] **Run:** `ls migrations/ | grep -E '^[0-9]+_' | sort -t_ -k1 -n | tail -3`
Expected output is three filenames whose numeric prefixes are consecutive.
Whatever the highest prefix is, the next available number is **(highest + 1)**.
- [ ] **Append to the findings doc** under the D1 heading:
```text
Highest existing migration: <NN>_<name>.up.sql
Next available number: <NN+1>
Sub-plan 1 will use migrations <NN+1> through <NN+4>.
```
(Replace the placeholders with the observed values.) This locks the
numbering used by Tasks 2–5. If the next number is not 139, update the
filenames in Tasks 2–5 accordingly when you reach them.
### Step 1.3: D2 — `media_libraries` kind/type column
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.media_libraries"`
Inspect the column list. Look for any column whose name or comment
suggests a per-library *kind* / *type* / *category* discriminator
(candidate names: `kind`, `library_type`, `category`, `media_type`,
`content_type`).
- [ ] **Append to the findings doc** under the D2 heading exactly one of:
- `No existing kind/type column on media_libraries. Task 4 adds 'kind' as planned.`
- `Existing column '<NAME>' (<TYPE>) discriminates library content. Task 4 becomes a no-op migration; the audiobook scanner branch will read '<NAME>' instead of 'kind'.`
### Step 1.4: D3 — `media_files.chapters` JSONB shape
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT chapters FROM media_files WHERE chapters IS NOT NULL AND jsonb_array_length(chapters) > 0 LIMIT 1;"`
This returns one sample chapter array used by silo's existing video
chapter feature (added in migration 066).
- [ ] **Append to the findings doc** under the D3 heading:
```text
Sample chapter JSON (live data):
<paste the JSON exactly as returned, no edits>
Sub-plan 2 (scanner) MUST emit objects with the same keys when writing
audiobook chapters so the existing player and serialization code accept
them without changes.
```
### Step 1.5: D4 — `user_watch_progress` scoping
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.user_watch_progress"`
- [ ] **Append to the findings doc** under the D4 heading exactly one of:
- `user_watch_progress is profile-scoped: column '<profile_id>' (FK to user_profiles). Audiobook progress slots in directly.`
- `user_watch_progress is user-scoped only (no profile column). Audiobook progress will share user state across all household profiles unless Sub-plan 3 adds a profile_id column. RAISE THIS AS A RISK in sub-plan 2 brainstorming.`
### Step 1.6: D5 — `user_playback_sessions` fit
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.user_playback_sessions"`
- [ ] **Append to the findings doc** under the D5 heading:
```text
Column list:
<paste the \d output verbatim>
Columns required by audiobook sessions: media_item_id (or equivalent),
profile/user FK, started_at, current_position_seconds (or equivalent),
status. Mark any required column as MISSING and surface in sub-plan 3.
```
### Step 1.7: D6 — `people` / `item_people` role conventions
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.item_people"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT DISTINCT role FROM item_people ORDER BY role;"
```
- [ ] **Append to the findings doc** under the D6 heading:
```text
item_people.role storage: <TEXT | enum named '<name>' | CHECK constraint>
Existing role values in use: <comma-separated list from the SELECT>
Sub-plan 2 will UPSERT 'author' and 'narrator' into item_people for
audiobook items. If the role column is constrained by an enum or CHECK,
Sub-plan 2 must add 'author' and 'narrator' to the constraint as part of
its first migration.
```
### Step 1.8: D7 — FTS handling of new `type` values
- [ ] **Run:**
```bash
grep -lE "type\s*=\s*'(movie|series|episode)'" migrations/*.up.sql | head -5
```
These migrations build/maintain silo's title search. Open each one and
scan for whether the type filter is hardcoded (`type IN ('movie','series')`)
or generic (no type filter, indexes everything).
- [ ] **Append to the findings doc** under the D7 heading:
```text
Indexes / generated columns that filter by media_items.type:
<bulleted list of (migration_file, what it filters)>
Verdict: audiobooks WILL / WILL NOT be FTS-searchable out of the box.
If WILL NOT: Sub-plan 3 must include an extra migration that extends the
type filter to include 'audiobook' and 'podcast'.
```
### Step 1.9: D8 — Scheduled-task registration
- [ ] **Run:**
```bash
grep -rEn "RegisterTask|TaskManager|RegisterScheduled|task\.Register" cmd/silo/main.go internal --include='*.go' | head -10
```
- [ ] **Append to the findings doc** under the D8 heading:
```text
First-party scheduled tasks register at: <file:line>
Registration call shape: <one-line copy of the call as used by an
existing first-party task such as smart-count refresh>
Sub-plan 5 (podcasts) will register podcastfeed.Refresher at the same
call site using the same pattern.
```
### Step 1.10: Commit
- [ ] **Run:**
```bash
git add docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 2: Migration 147 — `abs_sessions`
Parallel of `jellycompat_sessions` for Audiobookshelf clients. Lets ABS
mobile/desktop apps reconnect without re-authenticating against silo's
main `auth_sessions`.
**Files:**
- Create: `migrations/147_abs_sessions.up.sql`
- Create: `migrations/147_abs_sessions.down.sql`
> **Note:** If Task 1, Step 1.2 found the next number is not 139, rename
> both filenames to match. All other tasks downstream of 139 shift by the
> same delta.
### Step 2.1: Write the failing assertion
- [ ] **Run** (this should error — the table does not exist yet):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.abs_sessions"
```
Expected: `Did not find any relation named "public.abs_sessions".`
### Step 2.2: Create the up migration
- [ ] **Create** `migrations/147_abs_sessions.up.sql` with:
```sql
-- Audiobookshelf-compatible client sessions. Parallel to
-- jellycompat_sessions: each row identifies an ABS mobile/desktop
-- client by its device + token so it can reconnect without
-- re-authenticating against silo's main auth_sessions.
CREATE TABLE IF NOT EXISTS public.abs_sessions (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
device_id TEXT NOT NULL,
device_name TEXT,
client_name TEXT,
client_version TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
last_seen_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_abs_sessions_token_hash
ON public.abs_sessions (token_hash);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_user_device
ON public.abs_sessions (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_last_seen
ON public.abs_sessions (last_seen_at);
```
### Step 2.3: Create the down migration
- [ ] **Create** `migrations/147_abs_sessions.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_abs_sessions_last_seen;
DROP INDEX IF EXISTS public.idx_abs_sessions_user_device;
DROP INDEX IF EXISTS public.idx_abs_sessions_token_hash;
DROP TABLE IF EXISTS public.abs_sessions;
```
### Step 2.4: Apply the migration by restarting silo
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy:**
```bash
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
```
- [ ] **Verify silo applied the migration:**
```bash
sudo docker logs --since 2m silo-prod-silo-1 2>&1 | grep -i 'database migrations applied'
```
Expected: one line containing `database migrations applied`.
### Step 2.5: Verify the table exists and is usable
- [ ] **Run** (each command should now succeed):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.abs_sessions"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 147;"
```
Expected: `\d` prints the full column list; the `SELECT` returns one row with `version=147`.
- [ ] **Run** a sample insert/delete to prove constraints work:
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
INSERT INTO abs_sessions (user_id, token_hash, device_id, device_name, client_name, client_version)
VALUES ((SELECT id FROM users ORDER BY id LIMIT 1), 'test-token-hash-001', 'test-device-001', 'Test Device', 'AbsTestClient', '1.0.0');
DELETE FROM abs_sessions WHERE token_hash = 'test-token-hash-001';
"
```
Expected: `INSERT 0 1` then `DELETE 1`. (At least one user must exist.)
### Step 2.6: Commit
- [ ] **Run:**
```bash
git add migrations/147_abs_sessions.up.sql migrations/147_abs_sessions.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 147 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>
EOF
)"
```
---
## Task 3: Migration 157 — `podcast_feeds`
One row per subscribed podcast. The podcast itself lives in `media_items`
(`type='podcast'`); this side table carries the RSS-specific metadata the
feed refresher needs.
**Files:**
- Create: `migrations/157_podcast_feeds.up.sql`
- Create: `migrations/157_podcast_feeds.down.sql`
### Step 3.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.podcast_feeds"
```
Expected: `Did not find any relation named "public.podcast_feeds".`
### Step 3.2: Create the up migration
- [ ] **Create** `migrations/157_podcast_feeds.up.sql` with:
```sql
-- RSS-feed metadata for subscribed podcasts. One row per podcast
-- media_items row; the feed refresher polls feed_url every
-- refresh_interval_seconds and upserts new episodes into the existing
-- episodes table.
CREATE TABLE IF NOT EXISTS public.podcast_feeds (
media_item_id TEXT PRIMARY KEY
REFERENCES public.media_items(content_id) ON DELETE CASCADE,
feed_url TEXT NOT NULL,
etag TEXT,
last_modified TEXT,
last_refreshed_at TIMESTAMP WITH TIME ZONE,
last_refresh_error TEXT,
refresh_interval_seconds INTEGER NOT NULL DEFAULT 600,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_podcast_feeds_feed_url
ON public.podcast_feeds (feed_url);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_due_for_refresh
ON public.podcast_feeds (last_refreshed_at);
```
> **Note:** `media_items.content_id` is TEXT (silo uses snowflake-style
> string IDs, not BIGSERIAL — confirmed by the FK targets in other
> migrations). If Task 1 Step 1.4 sampled a numeric `content_id`,
> change the column type to match. Otherwise leave as TEXT.
### Step 3.3: Create the down migration
- [ ] **Create** `migrations/157_podcast_feeds.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_podcast_feeds_due_for_refresh;
DROP INDEX IF EXISTS public.idx_podcast_feeds_feed_url;
DROP TABLE IF EXISTS public.podcast_feeds;
```
### Step 3.4: Apply the migration
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy:**
```bash
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
```
### Step 3.5: Verify
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.podcast_feeds"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 157;"
```
Expected: column list printed; one row with `version=157`.
### Step 3.6: Commit
- [ ] **Run:**
```bash
git add migrations/157_podcast_feeds.up.sql migrations/157_podcast_feeds.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 157 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>
EOF
)"
```
---
## Task 4: Migration 159 — `media_libraries.kind` audiobook value (no-op)
Adds a discriminator column so the scanner can branch on
`audiobooks`/`podcasts` libraries without filename heuristics.
> **CONDITIONAL:** If Task 1 Step 1.3 (D2) recorded *"Existing column
> '<NAME>' discriminates library content"*, swap this whole task for a
> documented no-op: create both migration files as no-op SQL (`-- intentionally empty: existing column '<NAME>' covers this need`)
> and adjust downstream sub-plans to read `'<NAME>'` instead of `kind`.
> Otherwise, proceed with the steps below.
**Files:**
- Create: `migrations/159_media_folders_kind_noop.up.sql`
- Create: `migrations/159_media_folders_kind_noop.down.sql`
### Step 4.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT kind FROM media_libraries LIMIT 1;"
```
Expected: `column "kind" does not exist`.
### Step 4.2: Create the up migration
- [ ] **Create** `migrations/159_media_folders_kind_noop.up.sql` with:
```sql
-- Per-library content discriminator. Lets the scanner choose the right
-- parser (movie/tv extensions vs audiobook/podcast extensions) without
-- filename heuristics. Existing rows default to 'movies' to preserve
-- current behavior; operators set 'audiobooks' or 'podcasts' on the
-- libraries they want the new flavor for.
ALTER TABLE public.media_libraries
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'movies';
ALTER TABLE public.media_libraries
ADD CONSTRAINT media_libraries_kind_check
CHECK (kind IN ('movies', 'tv', 'audiobooks', 'podcasts'));
CREATE INDEX IF NOT EXISTS idx_media_libraries_kind
ON public.media_libraries (kind);
```
### Step 4.3: Create the down migration
- [ ] **Create** `migrations/159_media_folders_kind_noop.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_media_libraries_kind;
ALTER TABLE public.media_libraries
DROP CONSTRAINT IF EXISTS media_libraries_kind_check;
ALTER TABLE public.media_libraries
DROP COLUMN IF EXISTS kind;
```
### Step 4.4: Apply the migration
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy** (same command as Task 2 Step 2.4).
### Step 4.5: Verify
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT id, name, kind FROM media_libraries ORDER BY id;
"
```
Expected: every existing library row has `kind='movies'`.
- [ ] **Run** to confirm the CHECK rejects bad values:
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
INSERT INTO media_libraries (id, name, kind) VALUES (-9999, 'bad-test', 'garbage');
"
```
Expected: `ERROR: new row for relation "media_libraries" violates check constraint "media_libraries_kind_check"`.
- [ ] **Run** to clean up any test row that snuck through (no-op if the previous step rejected as expected):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "DELETE FROM media_libraries WHERE id = -9999;"
```
### Step 4.6: Commit
- [ ] **Run:**
```bash
git add migrations/159_media_folders_kind_noop.up.sql migrations/159_media_folders_kind_noop.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 159 document audiobooks media folder type
Per-library discriminator that lets the scanner pick the right parser
('movies', 'tv', 'audiobooks', 'podcasts') instead of inferring from
filename. Existing libraries default to 'movies'; operators set the new
values on the libraries they want indexed with audiobook/podcast logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Migration 160 — `audiobooks.enabled` feature flag
Adds a `server_settings` row so subsequent sub-plans can branch on
"audiobooks compiled in but turned off" vs "fully live". Default is
`false` so this sub-plan's landing is a strict no-op for users.
**Files:**
- Create: `migrations/160_audiobooks_feature_flag.up.sql`
- Create: `migrations/160_audiobooks_feature_flag.down.sql`
### Step 5.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT value FROM server_settings WHERE key = 'audiobooks.enabled';
"
```
Expected: zero rows.
### Step 5.2: Create the up migration
- [ ] **Create** `migrations/160_audiobooks_feature_flag.up.sql` with:
```sql
-- Master kill-switch for the absorbed audiobooks feature. Defaults to
-- 'false' so landing migrations 147/157/159/160 is a no-op for users; operators
-- flip this to 'true' at cutover.
INSERT INTO server_settings (key, value) VALUES ('audiobooks.enabled', 'false')
ON CONFLICT (key) DO NOTHING;
```
### Step 5.3: Create the down migration
- [ ] **Create** `migrations/160_audiobooks_feature_flag.down.sql` with:
```sql
DELETE FROM server_settings WHERE key = 'audiobooks.enabled';
```
### Step 5.4: Apply and verify
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy** (same command).
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT key, value FROM server_settings WHERE key = 'audiobooks.enabled';
"
```
Expected: one row, `value='false'`.
### Step 5.5: Commit
- [ ] **Run:**
```bash
git add migrations/160_audiobooks_feature_flag.up.sql migrations/160_audiobooks_feature_flag.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 160 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>
EOF
)"
```
---
## Task 6: Scaffold `internal/audiobooks/` package
Creates an empty but compiling Go package, wires its constructor into
`cmd/silo/main.go` so the package is referenced from the binary, and
ships a unit test that proves the `Enabled()` reader returns the flag
value seeded by migration 160.
This task proves the package compiles inside the rest of silo without
changing any runtime behavior. No routes are mounted. No scheduled
tasks are registered. No DB writes happen.
**Files:**
- Create: `internal/audiobooks/doc.go`
- Create: `internal/audiobooks/service.go`
- Create: `internal/audiobooks/service_test.go`
- Modify: `cmd/silo/main.go` (one new constructor call near the other service constructions)
### Step 6.1: Write the failing test
- [ ] **Create** `internal/audiobooks/service_test.go` with:
```go
package audiobooks
import (
"context"
"errors"
"testing"
)
type fakeSettingsReader struct {
value string
err error
}
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
if key != "audiobooks.enabled" {
return "", errors.New("unexpected key: " + key)
}
return f.value, f.err
}
func TestServiceEnabledReadsFlag(t *testing.T) {
cases := []struct {
name string
stored string
want bool
}{
{"flag true", "true", true},
{"flag false", "false", false},
{"flag empty defaults false", "", false},
{"flag garbage defaults false", "yes-please", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
svc := New(&fakeSettingsReader{value: tc.stored})
got, err := svc.Enabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
}
if got != tc.want {
t.Fatalf("Enabled = %v, want %v", got, tc.want)
}
})
}
}
func TestServiceEnabledPropagatesError(t *testing.T) {
wantErr := errors.New("db down")
svc := New(&fakeSettingsReader{err: wantErr})
_, err := svc.Enabled(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
}
}
```
### Step 6.2: Run the test to verify it fails
- [ ] **Run:** `go test ./internal/audiobooks/...`
Expected: build failure with messages like `undefined: New` and
`package audiobooks not found`.
### Step 6.3: Write the package doc
- [ ] **Create** `internal/audiobooks/doc.go` with:
```go
// Package audiobooks owns silo's first-party audiobook + podcast feature,
// absorbed from the historical silo-plugin-audiobooks. Sub-plan 1 lands
// only the package scaffold and the kill-switch reader; ABS-compat REST,
// Socket.io, scanner branches, podcast feed refresh, and the silo SPA
// pages arrive in later sub-plans.
//
// See docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md
// for the design and docs/superpowers/plans/2026-05-24-audiobooks-*.md
// for the staged implementation plans.
package audiobooks
```
### Step 6.4: Write the service
- [ ] **Create** `internal/audiobooks/service.go` with:
```go
package audiobooks
import (
"context"
"fmt"
)
// SettingsReader is the minimal slice of the server-settings store that
// the audiobooks service needs. The production implementation is
// internal/serversettings.Store (or whatever silo names that helper at
// wiring time); tests pass a fake.
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.
type Service struct {
settings SettingsReader
}
// New constructs a Service. The constructor takes the dependencies it
// will actually use; current sub-plan needs only the settings reader.
func New(settings SettingsReader) *Service {
return &Service{settings: settings}
}
// 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) {
if s == nil || s.settings == nil {
return false, nil
}
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
if err != nil {
return false, fmt.Errorf("audiobooks: read audiobooks.enabled: %w", err)
}
return value == "true", nil
}
```
### Step 6.5: Run the test to verify it passes
- [ ] **Run:** `go test ./internal/audiobooks/...`
Expected: `ok github.com/Silo-Server/silo-server/internal/audiobooks ...`
### Step 6.6: Wire the constructor into `cmd/silo/main.go`
The audit in Task 1 Step 1.9 located the function where first-party
services are constructed at startup. Open `cmd/silo/main.go`, find the
block where other internal services (e.g. metadata, downloads, catalog)
are constructed and pass a settings store to.
- [ ] **Run** to find the right insertion point:
```bash
grep -nE 'serversettings|server_settings|settingsStore|metadata\.NewService|downloads\.NewService' cmd/silo/main.go | head -10
```
Note the file:line of the existing service construction block.
- [ ] **Modify** `cmd/silo/main.go`: in the service-construction block (immediately after another similar `service := pkg.New(...)` line), add:
```go
audiobooksService := audiobooks.New(settingsStore)
_ = audiobooksService // referenced by sub-plan 2 onward; no behavior in sub-plan 1
```
Where `settingsStore` is whatever the existing services pass in for
the server-settings reader. The `_ = audiobooksService` line keeps Go
from rejecting an unused variable; it gets removed in sub-plan 2 when
the variable is actually used.
- [ ] **Add** the import to the import block at the top of `cmd/silo/main.go`:
```go
"github.com/Silo-Server/silo-server/internal/audiobooks"
```
### Step 6.7: Verify everything still compiles and tests pass
- [ ] **Run:** `go build ./...`
Expected: exit 0, no output.
- [ ] **Run:** `go test ./internal/audiobooks/... ./cmd/...`
Expected: all passes.
- [ ] **Run:** `make lint`
Expected: no new lint findings introduced by this task.
### Step 6.8: Smoke-test the running binary
- [ ] **Run:** `sudo docker compose -p silo-prod up -d --force-recreate silo`
- [ ] **Wait until healthy** (same command as Task 2 Step 2.4).
- [ ] **Run:**
```bash
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8090/api/v1/health
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8090/
```
Expected: same status codes silo returned before this sub-plan
started (typically `200` for `/`, `404` for `/api/v1/health` since
that path was 404 in the baseline logs). No new errors in `docker
logs silo-prod-silo-1`.
### Step 6.9: Commit
- [ ] **Run:**
```bash
git add internal/audiobooks/doc.go internal/audiobooks/service.go internal/audiobooks/service_test.go cmd/silo/main.go
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Self-review (done by the planner before handoff)
**Spec coverage:**
| Spec section | Task(s) that cover it |
|---|---|
| Data model: reused tables | Task 1 (verification only) |
| Data model: `abs_sessions` (migration 147) | Task 2 |
| Data model: `podcast_feeds` (migration 157) | Task 3 |
| Data model: `media_folders.type` audiobook value (migration 159 no-op) | Task 4 |
| Data model: data migration = none | n/a — confirmed in spec |
| Feature flag (`audiobooks.enabled`) | Task 5 |
| Architecture: `internal/audiobooks/` package | Task 6 |
| Risks: `media_libraries` column check | Task 1, Step 1.3 |
| Risks: `media_files.chapters` shape | Task 1, Step 1.4 |
| Risks: progress profile-scoping | Task 1, Step 1.5 |
| Risks: FTS handling of `audiobook` | Task 1, Step 1.8 |
| Risks: `people` role conventions | Task 1, Step 1.7 |
Out of sub-plan 1 scope (deferred to later sub-plans):
ABS REST port, Socket.io port, scanner branches, podcast refresher,
silo SPA pages, plugin retirement, cutover. Each is its own sub-plan
(2–6).
**Placeholder scan:** None. Every step has the actual command, the
actual SQL, or the actual Go to write. Conditional language is confined
to Task 4 (which is explicit about its condition and what to do if it
fires).
**Type consistency:** The fake `SettingsReader` interface in Task 6's
test matches the production interface defined in `service.go`. Method
name `GetString`, signature `(ctx context.Context, key string) (string, error)` — consistent across both files.
**Note on migration numbering:** Tasks 2–5 originally used numbers `139..142`
based on a snapshot showing `138_search_number_word_normalization.up.sql` as
the highest existing migration. The landed implementation was renumbered to
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
`160_audiobooks_feature_flag`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
# Audiobooks Absorption — Sub-plan 5: Podcasts (RSS feed refresher)
**Goal:** Port the plugin's `podcastfeed` package as a first-party scheduled task in silo, so RSS-subscribed podcasts get their episode lists kept up to date.
**Source plugin:** `../silo-plugin-audiobooks/internal/podcastfeed/refresher.go` (353 LOC) + `refresher_test.go` (295 LOC)
## Tasks
### Task 1: Port the refresher package
- Copy `refresher.go` + `refresher_test.go` from plugin to `internal/audiobooks/podcastfeed/`
- Update imports (drop plugin SDK; use silo's `pgxpool` + `log/slog`)
- Rewrite SQL queries against silo's `podcast_feeds` table (created in sub-plan 1 migration 140), `media_items` (type='podcast'), and `episodes`
- Drop the plugin's `presentation_libraries` reference; subscriptions are tracked by `podcast_feeds.media_item_id`
### Task 2: Wire as scheduled task
- Discovery D8 documented silo's task registration pattern: `taskMgr.Register(tasks.NewXxxTask(...))` in `cmd/silo/main.go`
- Add `tasks.NewSyncPodcastFeedsTask(refresher)` and register
- Default interval: 10 minutes (matches plugin)
### Task 3: ABS podcast endpoints (optional)
The plugin's `abs/podcast.go` + `abs/rss_feed_handler.go` + `abs/podcast_handler.go` expose:
- `GET /abs/api/libraries/{id}/podcasts/{podcastId}/episodes/{episodeId}`
- `POST /abs/api/libraries/{id}/podcasts` (add subscription)
- `GET /abs/api/podcast/feeds` (admin RSS feed browse)
If reach is required for ABS clients, port these. Otherwise stub 501 and ship. For initial release, stub.
### Task 4: Build + smoke
- Verify scheduled task registers on startup (`docker logs` shows the task)
- Verify nothing else breaks
## Risks
- Plugin's RSS parser may depend on third-party packages not in silo's go.mod. Check go.mod after porting and add if needed.
- `episodes` table in silo has a different shape than the plugin assumed. Re-read the plugin's INSERT statements and adapt.
@@ -0,0 +1,153 @@
# Audiobooks Absorption — Discovery Findings
Produced by sub-plan 1, Task 1. Locks data-model and integration
decisions for the audiobook foundation migrations and downstream sub-plans.
## D1 — Next migration number
Original snapshot: `138_search_number_word_normalization.up.sql` was the
highest existing migration. The landed implementation was renumbered to
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
`160_audiobooks_feature_flag`.
## D2 — `media_libraries` kind/type column
The spec refers to `media_libraries` but the actual table is `media_folders`.
`media_folders` has an existing column `type` (text, NOT NULL) that discriminates
library content. Current values in production: `movies`, `series`, `mixed`.
Existing column 'type' (text) discriminates library content on `media_folders`.
Task 4 (migration 159) should ADD the value `audiobooks` to the type vocabulary
rather than add a new column. The audiobook scanner branch will set
`media_folders.type = 'audiobooks'` for audiobook libraries. No schema change
needed for the column itself; migration 159 becomes a no-op DDL migration that
documents the new allowed value and adds any supporting indexes if needed.
No CHECK constraint or enum enforces the `type` column values, so adding
`audiobooks` as a value requires no DDL constraint change.
## D3 — `media_files.chapters` JSONB shape
Sample chapter JSON (live data):
[{"index": 0, "title": "Intro start", "source": "embedded", "end_seconds": 27.944, "start_seconds": 0}, {"index": 1, "title": "Intro end", "source": "embedded", "end_seconds": 1343, "start_seconds": 27.944}]
Sub-plan 2 (scanner) MUST emit objects with the same keys when writing
audiobook chapters so the existing player and serialization code accept
them without changes.
Required keys: `index` (integer), `title` (text), `source` (text),
`start_seconds` (float), `end_seconds` (float).
## D4 — `user_watch_progress` scoping (profile vs user)
user_watch_progress is profile-scoped: column 'profile_id' (text, NOT NULL, FK via
composite PK on user_id + profile_id + media_item_id). Audiobook progress slots in directly.
Additional context: the table also stores `last_file_id`, `last_resolution`,
`last_hdr`, `last_codec_video`, and `last_edition_key`. For audiobooks, only
`position_seconds`, `duration_seconds`, `completed`, and `last_file_id` are
semantically relevant; the video-specific columns (`last_resolution`,
`last_hdr`, `last_codec_video`) will be NULL for audiobook progress rows,
which is acceptable.
## D5 — `user_playback_sessions` audiobook fit
Column list:
Table "public.user_playback_sessions"
Column | Type | Collation | Nullable | Default
------------------+--------------------------+-----------+----------+---------
session_id | text | | not null |
user_id | integer | | not null |
profile_id | text | | not null |
media_file_id | integer | | not null |
play_method | text | | not null |
position_seconds | double precision | | not null | 0
is_paused | boolean | | not null | false
started_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
Columns required by audiobook sessions: media_item_id (or equivalent),
profile/user FK, started_at, current_position_seconds (or equivalent),
status. Mark any required column as MISSING and surface in sub-plan 3.
Assessment:
- media_item_id: MISSING — table stores `media_file_id` (FK to media_files) rather
than `media_item_id`. For audiobooks, a file maps to one audiobook item, so the
item can be looked up via the file join. No schema change strictly required, but
sub-plan 3 should note this indirect join cost.
- profile_id: PRESENT (text, not null)
- user_id: PRESENT (integer, not null)
- started_at: PRESENT
- position_seconds: PRESENT (as `position_seconds`)
- status / is_paused: PRESENT (as `is_paused`); no explicit `status` enum, but
paused/playing state is representable.
- `play_method`: required for existing sessions; audiobook sessions must supply a
value (e.g. `'direct'`).
No blocking gaps. Audiobook sessions can be written to `user_playback_sessions`
without migration using `media_file_id` as the join key.
## D6 — `people` / `item_people` role conventions
item_people.role storage: `kind` smallint (NOT NULL) — NOT a text `role` column.
The column is named `kind` with type `smallint`. No CHECK constraint or enum.
Existing kind values in use (mapped from models/media.go):
1 = Actor, 2 = Director, 3 = Writer, 4 = Producer, 5 = GuestStar, 6 = Composer
(6 = Composer defined in code but 0 rows in production data)
Sub-plan 2 will UPSERT `author` and `narrator` into item_people for
audiobook items. Since the role column is an unconstrained smallint (not
a text role column and not an enum or CHECK constraint), Sub-plan 2 must:
1. Add new PersonKind constants to `internal/models/media.go`:
`PersonKindAuthor PersonKind = 7` and `PersonKindNarrator PersonKind = 8`
2. Add corresponding cases to `PersonKind.String()` returning `"Author"` and
`"Narrator"` respectively.
No migration is needed to extend a constraint — the smallint column accepts
any integer value.
## D7 — Catalog FTS handling of `type='audiobook'`
Indexes / generated columns that filter by media_items.type:
- `001_schema.up.sql`: `idx_media_items_search` — GIN on `to_tsvector('english', title || ' ' || overview)` — NO type filter, indexes ALL rows
- `001_schema.up.sql`: `idx_media_items_search_exact_title` — btree on `lower(title)` — NO type filter
- `001_schema.up.sql`: `idx_media_items_search_overview` — GIN on overview tsvector — NO type filter
- `001_schema.up.sql`: `idx_media_items_search_title_fields` — GIN on weighted title/original_title/sort_title tsvector — NO type filter (rebuilt by migrations 127 and 138)
- `001_schema.up.sql`: `idx_media_items_type_created` — btree on `(type, created_at DESC)` — indexes ALL types, used for filtering by type
- `057_calendar_indexes.up.sql`: `idx_media_items_movie_release_date` — btree WHERE `type = 'movie'` — movie-only, not FTS
- `103_media_items_last_air_date_denorm.up.sql`: `idx_media_items_last_air_date_at` — btree WHERE `type = 'series'` — series-only, not FTS
- `105_media_items_title_normalized.up.sql`: `idx_media_items_title_normalized_trgm` — gin trigram on `title_normalized` — NO type filter (rebuilt by 127 and 138)
- `138_search_number_word_normalization.up.sql`: `idx_media_items_search_title_fields` (current) — GIN on weighted tsvector — NO type filter
- `138_search_number_word_normalization.up.sql`: `idx_media_items_title_normalized_trgm` (current) — gin trigram — NO type filter
Verdict: audiobooks WILL be FTS-searchable out of the box.
All FTS indexes on `media_items` operate on the full table with no type
restriction. A row with `type = 'audiobook'` will be indexed automatically
by `idx_media_items_search_title_fields` and `idx_media_items_title_normalized_trgm`
as soon as it is inserted. No extra migration is needed for Sub-plan 3 to
extend type filters.
## D8 — First-party scheduled-task registration
First-party scheduled tasks register at: `cmd/silo/main.go:1239–1285`
Registration call shape (from existing first-party tasks):
```go
taskMgr.Register(tasks.NewSyncCollectionsTask(collectionSyncScheduler))
```
Full interface required (from `internal/taskmanager/tasks/sync_collections.go`):
- `Key() string` — unique string key e.g. `"sync_podcast_feeds"`
- `Name() string` — human-readable name
- `Description() string` — human-readable description
- `Category() taskmanager.TaskCategory` — e.g. `taskmanager.TaskCategoryLibrary`
- `IsHidden() bool`
- `DefaultTriggers() []taskmanager.TriggerConfig` — e.g. interval trigger
- `Execute(ctx context.Context, progress taskmanager.ProgressReporter) error`
Sub-plan 5 (podcasts) will register `podcastfeed.Refresher` at `cmd/silo/main.go`
in the task registration block (around line 1259–1265) using the same pattern:
```go
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastFeedRefresher))
```
@@ -0,0 +1,382 @@
# Audiobooks Absorption — Design Spec
**Date:** 2026-05-24
**Branch context:** `feat/audiobooks` in `silo-server`
**Status:** Approved — ready for implementation plan
## Goal
Absorb the `silo-plugin-audiobooks` plugin into the primary `silo-server`
Go application. Eliminate it as a plugin. User-visible outcome:
- Silo's existing web UI gains audiobook + podcast browse/playback.
- Audiobookshelf-compatible (ABS) mobile/desktop clients connect to silo
directly and play audiobooks from libraries silo already scans.
## Hard constraints
- **Minimize changes.** Reuse silo-server's existing tables and infrastructure
wherever they fit. Add new tables only where there is no existing equivalent.
- **No separate SPA.** Audiobook UI lives inside silo's existing React app at
`web/src/`. The plugin's standalone SPA is dropped.
- **Audiobook files = scanned by silo.** Silo's existing scanner discovers
audiobook files in configured library paths the same way it scans movies
and TV. The `silo-plugin-local-audiobooks` plugin is no longer needed.
- **No external backends.** BookWarehouse / other audiobook backend plugins
are out of scope. Local filesystem is the only source.
- **No standalone HTTP listener.** Everything runs on silo's main `:8080`
listener, including ABS Socket.io. The plugin's standalone-listener
workaround existed only because the host plugin-proxy could not bridge
WebSocket upgrades; that problem disappears once code runs in-process.
## Scope
### In
- Audiobook entity, library, playback (silo SPA + ABS clients).
- ABS-compatible REST API + Socket.io realtime channel.
- Podcasts: subscribed RSS feeds with episode refresh, plus filesystem podcasts.
- Per-profile listening progress, active play sessions, basic chapter navigation.
### Out
- Audiobook requests flow (`silo-plugin-audiobook-requests` untouched).
- Smart collections, share links, content restrictions, metadata-provider integration.
- Embedding-powered "similar books" recommender.
- External enrichment through the audiobook metadata plugin/provider — deferred;
scanner extracts local tags and identifiers for v1.
- Group listening / cowatch parity for audiobooks (silo already has cowatch for
video; revisit later if needed).
## Architecture
### Package layout
New top-level Go package: `internal/audiobooks/`.
```text
internal/audiobooks/
abs/ ← ported from plugin's internal/abs/ (~6.8k LOC)
ABS-compatible REST handlers, mounted under /abs/* and the
legacy /api/* paths ABS clients hardcode.
abssocket/ ← ported from plugin's internal/abssocket/ (~250 LOC)
Socket.io protocol on top of gorilla/websocket; optional
Redis pub/sub adapter for multi-replica fan-out.
podcastfeed/ ← ported from plugin's internal/podcastfeed/ (~350 LOC)
RSS feed refresher; registered as a scheduled task.
service.go ← thin orchestrator wiring the above to silo's existing
catalog/playback/session/auth services.
```
### Wiring touchpoints in existing silo code
| Existing file | Change |
|---|---|
| `internal/api/router.go` | Mount silo-native audiobook routes under `/api/v1/audiobooks/*`; mount ABS routes under `/abs/*` plus the small set of legacy `/api/*` paths ABS clients hit; mount the `/abs/socket.io/` endpoint. |
| `internal/scanner/` | Per-library dispatch on `media_libraries.kind`. New parsers for `audiobooks` and `podcasts`. |
| `cmd/silo/main.go` (task wiring) | Register `podcastfeed.Refresher` as a scheduled task. |
| `internal/playback/` | Accept `media_items.type='audiobook'` and `'podcast_episode'` (mostly already generic). |
| `web/src/pages/`, `web/src/player/` | New audiobook/podcast pages and player component. |
### What the plugin contributed that is dropped
The plugin's other packages — `server`, `store`, `enrich`, `recommend`,
`smartcoll`, `event`, `consumer`, `libsync`, `migrate`, `runtime`,
`bookref`, `cdn`, `mediatoken`, `streaming` — are either dropped (out-of-scope
features) or replaced by reuse of silo's existing equivalents (catalog store,
media-token signing, CDN, streaming, event bus, migrations runner).
### Total code budget
- ~7.4k LOC ported from the plugin.
- ~1.5–2k LOC new TypeScript/TSX in silo's SPA.
- ~400 LOC of new Go glue (scanner branches, router wiring, service.go).
- Three new SQL migrations in the current stack (`147_abs_sessions`,
`157_podcast_feeds`, `160_audiobooks_feature_flag`) plus one documented
no-op migration for the already-present media-folder type column.
## Data model
### Reused silo tables (no schema changes)
| Audiobook concept | Silo table | Notes |
|---|---|---|
| Audiobook entity | `media_items` | New value: `type='audiobook'`. Existing title/year/overview/poster/sort_title cover the basics. |
| Podcast (show) | `media_items` | New value: `type='podcast'`. |
| Podcast episode | `episodes` | Parallel to TV episodes; FK to parent `media_items` row. |
| Audio file + chapters | `media_files` | The `chapters jsonb` column from migration 066 stores chapters exactly as silo already does for video. |
| Library scope | `media_libraries` + `media_folders` + `media_item_libraries` | Audiobook libraries are libraries with `kind='audiobooks'` (see below). |
| Author / narrator | `people` + `item_people` | Two new role string constants: `'author'`, `'narrator'`. |
| Listening position | `user_watch_progress` | Per-profile position in file. Naming reads as video-only but works fine for audio. |
| Active play session | `user_playback_sessions` | Generic enough to host audiobook sessions. |
| Generic auth | `auth_sessions` | Used by silo's own clients. ABS clients use the new `abs_sessions` table. |
| Series / author shelves | `library_collections` + `library_collection_items` | Reuse for audiobook series, "by author" collections. |
### New tables (two migrations)
1. **`abs_sessions`** (migration 147) — parallel to existing `jellycompat_sessions`. Tracks ABS client device, token hash, last-seen so ABS apps reconnect without re-auth. Columns: `id`, `user_id`, `token_hash`, `device_id`, `device_name`, `abs_client_version`, `created_at`, `last_seen_at`. Same shape as `jellycompat_sessions` plus the `abs_client_version` text.
2. **`podcast_feeds`** (migration 157) — one row per subscribed podcast. Columns: `media_item_id` (FK to the podcast `media_items` row), `feed_url`, `etag`, `last_refreshed_at`, `refresh_interval_seconds`. Episode rows live in the reused `episodes` table.
### Schema-touch column add
- `media_libraries.kind` — `'movies' | 'tv' | 'audiobooks' | 'podcasts'`. If a
similar column already exists on `media_libraries`, reuse it; otherwise this
is a third small migration. The scanner reads it to pick the per-library
parser.
### Plugin migrations NOT ported
Smart collections, share links, embeddings, content restrictions,
metadata-provider integration, request provider, file cache, listening stats aggregates,
reading goals, notification prefs, standalone-mode tables, recommender
embeddings. All represent features dropped from scope.
### Data migration
None. The plugin's `audiobooks.*` Postgres schema is left intact until cutover
is confirmed, then dropped. Silo's scanner repopulates from the filesystem on
first run. ABS clients re-authenticate once (their tokens lived in the plugin
DB, not silo's). This is the one user-visible cost.
## Routing & ABS-compatibility
All routes mount on silo's main `:8080` HTTP listener.
### 1. Silo-native audiobook API
- Prefix: `/api/v1/audiobooks/*`.
- Examples: `GET /api/v1/audiobooks/library/{id}/items`,
`GET /api/v1/audiobooks/{item_id}`,
`POST /api/v1/audiobooks/{item_id}/progress`.
- Mounted next to other v1 routes. Standard silo auth middleware. JSON shapes
follow silo's existing patterns — not the ABS shape.
### 2. ABS-compatible API
- Prefix: `/abs/*` for the bulk of client-facing endpoints, plus the small set
of `/api/*` paths ABS clients hardcode (e.g. `/api/libraries`, `/api/me`,
`/api/items/{id}`). To avoid colliding with silo's existing `/api/v1/*`,
ABS gets its full namespace at `/abs/`; ABS-legacy `/api/*` paths are
registered explicitly and scoped to ABS auth.
- Auth: a separate middleware `audiobooks.RequireABSSession` validates the ABS
bearer token against `abs_sessions`.
- Login: `POST /abs/login` accepts ABS-shaped credentials, internally calls
silo's existing auth backend (same code path as `POST /api/v1/auth/login`),
then issues an ABS token bound to a row in `abs_sessions`. No password
handling lives in audiobooks code itself.
### 3. ABS Socket.io endpoint
- Path: `/abs/socket.io/`.
- Uses silo's existing `gorilla/websocket` upgrader. The ported `abssocket/`
package handles Socket.io handshake, framing, and the long-polling fallback.
- Multi-replica fan-out: if `REDIS_URL` is set, use a Redis pub/sub adapter
(already implemented in plugin). Unset → in-memory single-replica.
### What goes away
- No standalone listener — main `:8080` only.
- No `SILO_HOST_URL` / `SILO_PLUGIN_TOKEN` env plumbing — those existed because
the plugin called back into silo over HTTP. Direct Go calls replace this.
- No host plugin-proxy in the request path.
### Streaming reuse
ABS stream URLs internally rewrite to silo's existing
`/api/v1/stream/{session_id}` machinery — direct play, range requests,
transcode fallback if needed, media-token signing. **Zero new transcode or
stream code.**
## Scanner integration
### Per-library dispatch (~30 LOC in `internal/scanner`)
The scanner reads `media_libraries.kind` and picks a parser. Existing
`movies`/`tv` paths are unchanged. New branches: `audiobooks`, `podcasts`.
### Audiobook parser (~200 LOC, `internal/scanner/audiobook.go`)
- Folder convention: one audiobook = one folder. Files = chapters in filename
order, or one `.m4b` with embedded chapters.
- Recognized extensions: `.m4b`, `.mp3`, `.m4a`, `.flac`, `.opus`.
- Single `.m4b`: one `media_items` row + one `media_files` row. Embedded
chapters extracted via the existing ffprobe call site → `media_files.chapters`.
- Multi-file folder: one `media_items` row + N `media_files` rows. Chapter
index = filename ordering; each file's `chapters` carries one synthesized
chapter for that file.
- Metadata: ID3 / MP4 tags (title, author, narrator, series, year, cover).
Reuses silo's existing tag-extraction helpers.
### Podcast parser (~150 LOC, `internal/scanner/podcast.go`)
Two paths:
1. **Filesystem podcasts** (downloaded episodes on disk): folder = podcast,
file = episode. Same `series → episodes` shape silo already handles for TV.
2. **RSS-subscribed podcasts**: no filesystem walk. The `podcastfeed.Refresher`
scheduled task fetches RSS, upserts `media_items` (podcast) + `episodes`,
optionally downloads enclosures to a configured cache dir (reusing silo's
existing download/cache infrastructure). `media_files` rows point at the
cached file once downloaded.
### Author / narrator extraction
Tag-derived names → upsert into `people` → link via `item_people` with roles
`'author'` / `'narrator'`. Same upsert pattern silo already uses for actors
and directors. No new code, just two more role string constants.
### External enrichment
Deferred. The scanner extracts local tags and identity hints only. Canonical
enrichment remains the responsibility of the audiobook metadata plugin/provider
path and is not ported in this foundation pass.
### Scheduled task
`podcastfeed.Refresher` runs every 10 minutes (matches plugin behavior).
Registered with silo's existing task manager. No other new background jobs.
### Impact on existing scanner behavior
None for movie/TV libraries. Audiobook/podcast libraries were not being
scanned before; now they are when `kind` is set appropriately.
## Web UI surfaces
### Stack
Silo's existing React 19 + Vite SPA at `web/src/`. React Query, radix-ui,
tailwind. The plugin's SPA uses the same stack so component idioms transfer
cleanly, but we are writing fresh silo pages — the plugin's `web/` is dropped.
### New pages under `web/src/pages/audiobooks/`
| Route | Purpose |
|---|---|
| `/audiobooks` | Home: continue-listening shelf, recent additions, library-wide browse. |
| `/audiobooks/library/:id` | Library view: grid of audiobooks, filter/sort, paginated. |
| `/audiobooks/book/:id` | Detail: cover, author, narrator, series, chapter list, play/continue CTA. |
| `/audiobooks/authors`, `/audiobooks/series` | Index pages built on `library_collections`. |
| `/podcasts` | Subscribed podcasts grid. |
| `/podcasts/show/:id` | Podcast detail + episode list. |
| `/podcasts/episode/:id` | Episode detail + play. |
### Player
`web/src/player/AudiobookPlayer.tsx`:
- HTML5 `<audio>` (no HLS / transcoding needed for typical audio). Falls back
to silo's existing transcode flow only if the file needs it.
- Chapter list panel, sleep timer, playback rate (0.5×–3×), 30-second seek
buttons, skip-silence toggle.
- Position updates via `POST /api/v1/audiobooks/{id}/progress` at 5–10s
intervals + on pause/seek. Mirrors silo's existing video progress cadence.
### Navigation
Add "Audiobooks" and "Podcasts" to the existing sidebar/library switcher,
driven by `media_libraries.kind`. Same dispatch point silo uses today for
Movies vs TV.
### Reused silo components (no copies)
Card grids, library shelves, search box, profile/auth chrome, image cache,
virtualized list (`@tanstack/react-virtual` already in `package.json`).
### Not building (out of scope)
Smart collections UI, share-link manager, request submission UI, embeddings
"similar books", admin enrich button, content-restriction admin.
### Frontend volume
Roughly 8 new page components + 1 player + a handful of audiobook-specific
hooks/types under `web/src/hooks/audiobooks/` and `web/src/lib/audiobooks/`.
~1.5–2k LOC of new TS/TSX total. No new top-level dependencies beyond what
silo's `package.json` already has.
## Plugin retirement & rollout
### Sibling repos
| Repo | Action |
|---|---|
| `silo-plugin-audiobooks` | Archive on GitHub; remove from catalog. |
| `silo-plugin-local-audiobooks` | Archive (silo scanner replaces it). |
| `silo-plugin-bookwarehouse-audio` | Archive (out of scope). |
| `silo-plugin-audiobook-requests` | Untouched — request flow out of scope. |
Stance: **archive, do not delete.** Leaves a recoverable home for any feature
dropped from this port that might come back later.
### Catalog update
One-line PR in `silo-plugins`: remove the three audiobook-related plugin
entries from `manifest.json`. The fourth (audiobook-requests) stays.
### Rollout sequence
1. Land the absorbed code in silo-server behind a feature-flag server setting
(`audiobooks.enabled`, default off).
2. Side-by-side: silo-server has audiobooks compiled in, the plugin is still
installed and running on this host. Verify silo's flow end-to-end
(scanner → SPA → ABS clients).
3. Stop the plugin runtime. Flip `audiobooks.enabled` on. ABS clients
reconnect to silo's `/abs/*` endpoints (config change in the ABS app if
the base URL differs).
4. Remove plugin entries from catalog; archive repos.
5. After confidence in cutover, `DROP SCHEMA audiobooks CASCADE` in Postgres.
### Rollback
Revert the silo flag. Until step 4 ships, the plugin is still installed and
runnable — single-flag rollback.
### Data migration
None. Scanner repopulates from filesystem on first run. ABS clients
re-authenticate once. This is the one user-visible cost.
## Testing
- **Unit tests** for the ported `abs/`, `abssocket/`, `podcastfeed/` packages
carry over from the plugin where they apply; rewrite tests that depended on
the plugin's separate schema.
- **Integration tests** against the real silo Postgres (per project preference
to avoid mocking the DB):
- Scanner: audiobook folder → `media_items` + `media_files` + chapters.
- ABS auth: `POST /abs/login` issues a token rooted in silo's `users`.
- ABS playback: GET a library → GET an item → stream a chapter → progress upsert.
- Podcast refresher: feed URL → upserted episodes.
- **Manual E2E**: an ABS mobile client points at silo, browses, plays, scrubs
through chapters, sleep-timer expires; silo SPA shows the listening
progress and resumes from the right chapter.
## Risks & open questions
- **Socket.io protocol port quality.** The plugin's `abssocket/` is ~250 LOC of
Socket.io framing on top of gorilla/websocket. It works today for the
plugin; once mounted on silo's main listener, verify the long-polling
fallback path still works under silo's middleware stack (auth, rate-limit,
request-id).
- **`media_libraries.kind` column.** If the existing schema already has an
equivalent column under a different name, use that instead. Verify before
writing migration 134/135.
- **`media_files.chapters` shape compatibility.** Silo's chapter JSONB shape
was designed for video chapters. Confirm the audiobook ffprobe output maps
cleanly into the same shape, or extend the JSON tolerantly.
- **Search.** The plugin had its own search index for books/authors. Silo's
existing FTS (catalog/pg-fts) needs to index audiobooks too; verify that
setting `type='audiobook'` is enough or whether the FTS index needs a small
config tweak.
- **Profile-scoped vs user-scoped state.** `user_watch_progress` is keyed —
confirm it's keyed on profile, not just user (CLAUDE.md notes silo
separates login accounts from household profiles).
## Next step
Hand off to the `writing-plans` skill to produce a phased implementation plan
covering migrations, package ports, router wiring, scanner branches, SPA
pages, and cutover. The plan's first phase should resolve the open questions
in the **Risks** section before code lands.
+10
View File
@@ -0,0 +1,10 @@
// Package audiobooks owns silo's first-party audiobook + podcast feature,
// absorbed from the historical silo-plugin-audiobooks. Sub-plan 1 lands
// only the package scaffold and the kill-switch reader; ABS-compat REST,
// Socket.io, scanner branches, podcast feed refresh, and the silo SPA
// pages arrive in later sub-plans.
//
// See docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md
// for the design and docs/superpowers/plans/2026-05-24-audiobooks-*.md
// for the staged implementation plans.
package audiobooks
+43
View File
@@ -0,0 +1,43 @@
package audiobooks
import (
"context"
"fmt"
)
// SettingsReader is the minimal slice of the server-settings store that
// the audiobooks service needs. The production implementation is
// internal/catalog.ServerSettingsRepo (or whatever silo names that helper at
// wiring time); tests pass a fake.
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.
type Service struct {
settings SettingsReader
}
// New constructs a Service. The constructor takes the dependencies it
// will actually use; current sub-plan needs only the settings reader.
func New(settings SettingsReader) *Service {
return &Service{settings: settings}
}
// 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) {
if s == nil || s.settings == nil {
return false, nil
}
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
if err != nil {
return false, fmt.Errorf("read audiobooks.enabled: %w", err)
}
return value == "true", nil
}
+76
View File
@@ -0,0 +1,76 @@
package audiobooks
import (
"context"
"errors"
"testing"
)
type fakeSettingsReader struct {
value string
err error
}
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
if key != "audiobooks.enabled" {
return "", errors.New("unexpected key: " + key)
}
return f.value, f.err
}
func TestServiceEnabledReadsFlag(t *testing.T) {
cases := []struct {
name string
stored string
want bool
}{
{"flag true", "true", true},
{"flag false", "false", false},
{"flag empty defaults false", "", false},
{"flag garbage defaults false", "yes-please", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
svc := New(&fakeSettingsReader{value: tc.stored})
got, err := svc.Enabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
}
if got != tc.want {
t.Fatalf("Enabled = %v, want %v", got, tc.want)
}
})
}
}
func TestServiceEnabledPropagatesError(t *testing.T) {
wantErr := errors.New("db down")
svc := New(&fakeSettingsReader{err: wantErr})
_, err := svc.Enabled(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
}
}
func TestServiceEnabledNilReceiverReturnsFalse(t *testing.T) {
var svc *Service
got, err := svc.Enabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
}
if got {
t.Fatal("Enabled = true, want false")
}
}
func TestServiceEnabledNilSettingsReturnsFalse(t *testing.T) {
svc := New(nil)
got, err := svc.Enabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
}
if got {
t.Fatal("Enabled = true, want false")
}
}
+6
View File
@@ -212,6 +212,8 @@ const (
PersonKindProducer PersonKind = 4
PersonKindGuestStar PersonKind = 5
PersonKindComposer PersonKind = 6
PersonKindAuthor PersonKind = 7
PersonKindNarrator PersonKind = 8
)
// String returns the Jellyfin-compatible type string for this PersonKind.
@@ -229,6 +231,10 @@ func (k PersonKind) String() string {
return "GuestStar"
case PersonKindComposer:
return "Composer"
case PersonKindAuthor:
return "Author"
case PersonKindNarrator:
return "Narrator"
default:
return "Unknown"
}
+18
View File
@@ -0,0 +1,18 @@
package models
import "testing"
func TestPersonKindAudiobookRoles(t *testing.T) {
cases := []struct {
kind PersonKind
want string
}{
{PersonKindAuthor, "Author"},
{PersonKindNarrator, "Narrator"},
}
for _, tc := range cases {
if got := tc.kind.String(); got != tc.want {
t.Errorf("%d.String() = %q, want %q", tc.kind, got, tc.want)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package scanner
import (
"path/filepath"
"strings"
)
// audioExtensions is the set of file extensions the audiobook and podcast
// scanner branches recognize. Mirrors videoExtensions for video libraries.
var audioExtensions = map[string]bool{
".m4b": true,
".m4a": true,
".mp3": true,
".flac": true,
".opus": true,
".ogg": true,
}
// SupportsAudioFile reports whether the given path uses a recognized audio
// file extension.
func SupportsAudioFile(filePath string) bool {
if filePath == "" {
return false
}
return audioExtensions[strings.ToLower(filepath.Ext(filePath))]
}
+25
View File
@@ -0,0 +1,25 @@
package scanner
import "testing"
func TestSupportsAudioFile(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"book.m4b", true},
{"chapter1.mp3", true},
{"audio.M4A", true},
{"sample.flac", true},
{"podcast.opus", true},
{"track.ogg", true},
{"poster.jpg", false},
{"movie.mkv", false},
{"", false},
}
for _, tc := range cases {
if got := SupportsAudioFile(tc.path); got != tc.want {
t.Errorf("SupportsAudioFile(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package scanner
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// parsedAudiobook is the structured output of parseAudiobookFolder.
// The scanner write path (Task 8) converts this into media_items +
// media_files + item_people rows.
type parsedAudiobook struct {
Title string
Author string
Narrator string
Series string
Year int
Files []parsedAudiobookFile
}
// parsedAudiobookFile is one audio file belonging to a parsed audiobook.
// For single-file .m4b audiobooks there is exactly one entry; for
// multi-file folders (Task 7) there is one per file.
type parsedAudiobookFile struct {
Path string
Chapters []ChapterInfo
}
// parseAudiobookFolder reads a single audiobook folder and returns its
// structured representation. Recognized layouts:
// - one audio file in the folder, optionally with embedded chapters
// - multiple audio files in the folder; each becomes its own
// parsedAudiobookFile with a single synthesized chapter (title =
// filename stem); metadata comes from the first file's tags
//
// Returns an error wrapping os.ErrNotExist when the folder contains zero
// audio files, so the caller can skip it.
func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath string) (*parsedAudiobook, error) {
entries, err := os.ReadDir(folderPath)
if err != nil {
return nil, fmt.Errorf("read audiobook folder %s: %w", folderPath, err)
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if SupportsAudioFile(entry.Name()) {
audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name()))
}
}
if len(audioFiles) == 0 {
return nil, fmt.Errorf("audiobook folder %s: %w", folderPath, os.ErrNotExist)
}
sort.Strings(audioFiles)
book := &parsedAudiobook{}
if len(audioFiles) == 1 {
probed, err := ProbeFile(ctx, ffprobePath, audioFiles[0])
if err != nil {
return nil, fmt.Errorf("probe audiobook file %s: %w", audioFiles[0], err)
}
book.populateFromTags(probed.FormatTags)
book.Files = []parsedAudiobookFile{{
Path: audioFiles[0],
Chapters: probed.Chapters,
}}
return book, nil
}
// Multi-file case: read header from the first file, synthesize one
// chapter per file with title = filename stem. A 0..0 range means the
// chapter duration is unknown; consumers should treat it as non-seekable.
probedFirst, err := ProbeFile(ctx, ffprobePath, audioFiles[0])
if err != nil {
return nil, fmt.Errorf("probe first audiobook file %s: %w", audioFiles[0], err)
}
book.populateFromTags(probedFirst.FormatTags)
book.Files = make([]parsedAudiobookFile, 0, len(audioFiles))
for i, path := range audioFiles {
stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
book.Files = append(book.Files, parsedAudiobookFile{
Path: path,
Chapters: []ChapterInfo{{
Index: i,
Title: stem,
StartSeconds: 0,
EndSeconds: 0,
}},
})
}
return book, nil
}
// populateFromTags fills the audiobook's header fields (Title, Author,
// Narrator, Series, Year) from the ffprobe format tags. Tags are
// lower-cased by normalizeFormatTags upstream.
func (b *parsedAudiobook) populateFromTags(tags map[string]string) {
b.Title = firstNonEmpty(tags["title"], tags["album"])
b.Author = firstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"])
b.Narrator = firstNonEmpty(tags["narrator"], tags["performer"])
b.Series = firstNonEmpty(tags["album"], tags["series"], tags["mvnm"])
if year := firstNonEmpty(tags["date"], tags["year"]); year != "" {
if y := parseTagYear(year); y > 0 {
b.Year = y
}
}
}
// parseTagYear extracts a 4-digit year (e.g. 1900-9999) from a tag value
// that may be a bare year ("2024"), an ISO date ("2024-05-23"), or a
// padded form ("(2024)"). Returns 0 if no plausible year is found.
func parseTagYear(s string) int {
s = strings.TrimSpace(s)
for i := 0; i+4 <= len(s); i++ {
candidate := s[i : i+4]
if isAllDigits(candidate) {
year := 0
for _, c := range candidate {
year = year*10 + int(c-'0')
}
if year >= 1900 && year <= 9999 {
return year
}
}
}
return 0
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
+287
View File
@@ -0,0 +1,287 @@
package scanner
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/titleutil"
)
type filesystemRootContentFinder interface {
FindContentIDByRootPath(ctx context.Context, folderID int, rootPath, preferredType string) (string, error)
}
type filesystemMediaItemWriter interface {
Upsert(ctx context.Context, item *models.MediaItem) error
}
// ScanAudiobookFolder walks an audiobooks-typed media folder and writes
// one media_items row per subdirectory it can parse as an audiobook,
// plus the corresponding media_files rows and author/narrator links in
// item_people.
//
// Each immediate subdirectory of one of folder.Paths is treated as a
// single audiobook. Subdirectories that contain zero audio files are
// silently skipped (parseAudiobookFolder returns os.ErrNotExist).
//
// This bypasses the per-file movie/TV pipeline because audiobooks are
// inherently folder-scoped (one book = one item, possibly multi-file).
func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaFolder) error {
if s == nil || folder == nil {
return fmt.Errorf("ScanAudiobookFolder: nil scanner or folder")
}
var attempted int
var succeeded int
var failures []error
for _, root := range folder.Paths {
if err := ctx.Err(); err != nil {
return err
}
entries, err := os.ReadDir(root)
if err != nil {
slog.Warn("audiobook scan: read root failed", "root", root, "error", err)
attempted++
failures = append(failures, fmt.Errorf("read root %s: %w", root, err))
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subPath := filepath.Join(root, entry.Name())
if err := ctx.Err(); err != nil {
return err
}
attempted++
if err := s.reconcileAudiobookFolder(ctx, folder, subPath); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
slog.Warn("audiobook scan: folder failed",
"folder_id", folder.ID,
"path", subPath,
"error", err,
)
failures = append(failures, fmt.Errorf("%s: %w", subPath, err))
// Continue with siblings — one bad audiobook should not stop the scan.
continue
}
succeeded++
}
}
if attempted > 0 && succeeded == 0 && len(failures) > 0 {
return fmt.Errorf("audiobook scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...))
}
return nil
}
func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string) error {
parsed, err := parseAudiobookFolder(ctx, s.ffprobePath, folderPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("parse audiobook folder %s: %w", folderPath, err)
}
contentID, err := s.upsertAudiobookMediaItem(ctx, folder.ID, folderPath, parsed)
if err != nil {
return fmt.Errorf("upsert audiobook item: %w", err)
}
if err := s.upsertAudiobookMediaFiles(ctx, folder, contentID, folderPath, parsed); err != nil {
return fmt.Errorf("upsert audiobook files: %w", err)
}
if err := s.upsertAudiobookPeople(ctx, contentID, parsed); err != nil {
return fmt.Errorf("upsert audiobook people: %w", err)
}
slog.Info("audiobook scan: indexed",
"folder_id", folder.ID,
"content_id", contentID,
"title", parsed.Title,
"author", parsed.Author,
"files", len(parsed.Files),
)
return nil
}
// upsertAudiobookMediaItem reuses an item already linked to the same filesystem
// root, or creates a new row. It intentionally does not dedupe by title/year:
// audiobooks often share titles, unknown years, or edition-specific credits.
// Returns the content_id used.
func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, folderPath string, book *parsedAudiobook) (string, error) {
if s.itemRepo == nil {
return "", fmt.Errorf("itemRepo not configured on Scanner")
}
if s.fileRepo == nil {
return "", fmt.Errorf("fileRepo not configured on Scanner")
}
return resolveAudiobookMediaItem(ctx, s.fileRepo, s.itemRepo, folderID, folderPath, book)
}
func resolveAudiobookMediaItem(
ctx context.Context,
rootFinder filesystemRootContentFinder,
itemWriter filesystemMediaItemWriter,
folderID int,
folderPath string,
book *parsedAudiobook,
) (string, error) {
if rootFinder == nil {
return "", fmt.Errorf("root content finder not configured")
}
if itemWriter == nil {
return "", fmt.Errorf("media item writer not configured")
}
existingID, err := rootFinder.FindContentIDByRootPath(ctx, folderID, folderPath, "audiobook")
if err != nil {
return "", fmt.Errorf("find audiobook by root path: %w", err)
}
if existingID != "" {
return existingID, nil
}
id, err := idgen.NextID()
if err != nil {
return "", fmt.Errorf("generate content_id: %w", err)
}
item := &models.MediaItem{
ContentID: id,
Type: "audiobook",
Title: book.Title,
SortTitle: titleutil.DeriveDefaultSortTitle(book.Title),
Year: book.Year,
}
if err := itemWriter.Upsert(ctx, item); err != nil {
return "", err
}
return id, nil
}
// upsertAudiobookMediaFiles writes one media_files row per audio file in the
// parsed audiobook. The content_id ties each file back to the media_items row.
// folderPath is used as the canonical_root_path / observed_root_path.
func (s *Scanner) upsertAudiobookMediaFiles(
ctx context.Context,
folder *models.MediaFolder,
contentID string,
folderPath string,
book *parsedAudiobook,
) error {
for _, af := range book.Files {
chapters := make([]models.MediaChapter, len(af.Chapters))
for i, ch := range af.Chapters {
chapters[i] = models.MediaChapter{
Index: ch.Index,
Title: ch.Title,
StartSeconds: ch.StartSeconds,
EndSeconds: ch.EndSeconds,
Source: ch.Source,
}
}
mf := models.MediaFile{
ContentID: contentID,
MediaFolderID: folder.ID,
CanonicalRootPath: folderPath,
ObservedRootPath: folderPath,
ContentGroupKey: contentID,
GroupKeyVersion: 1,
BaseTitle: book.Title,
BaseYear: book.Year,
BaseType: "audiobook",
IdentityConfidence: audiobookIdentityConfidence(book, af),
FilePath: af.Path,
Chapters: chapters,
ProbeSource: "local",
}
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
return fmt.Errorf("upsert media file %s: %w", af.Path, err)
}
}
return nil
}
// upsertAudiobookPeople upserts author and narrator rows into item_people,
// using the PersonRepository to find-or-create each person by name. When the
// parsed credits differ, ReplacePeople makes scanner-derived credits authoritative.
// Skips the DELETE+INSERT entirely when the existing credit set already
// matches the desired set (case-insensitive on name, exact on kind).
func (s *Scanner) upsertAudiobookPeople(ctx context.Context, contentID string, book *parsedAudiobook) error {
if s.personRepo == nil {
return fmt.Errorf("personRepo not configured on Scanner")
}
type credit struct {
name string
kind models.PersonKind
}
var credits []credit
if book.Author != "" {
credits = append(credits, credit{book.Author, models.PersonKindAuthor})
}
if book.Narrator != "" {
credits = append(credits, credit{book.Narrator, models.PersonKindNarrator})
}
if len(credits) == 0 {
return nil
}
people := make([]models.ItemPerson, 0, len(credits))
for i, c := range credits {
personID, err := s.personRepo.FindOrCreate(ctx, models.Person{Name: c.name})
if err != nil {
return fmt.Errorf("find-or-create person %q: %w", c.name, err)
}
people = append(people, models.ItemPerson{
Person: models.Person{ID: personID},
Kind: c.kind,
SortOrder: i,
})
}
// ReplacePeople deletes existing credits then inserts the new set.
// For audiobook scanning this is acceptable — we re-derive all credits
// from the file metadata on every scan, so the set is authoritative.
return s.itemRepo.ReplacePeople(ctx, contentID, people)
}
func audiobookIdentityConfidence(book *parsedAudiobook, file parsedAudiobookFile) string {
if book == nil {
return "low"
}
score := 0
if book.Title != "" {
score++
}
if book.Author != "" || book.Narrator != "" {
score++
}
if book.Year > 0 {
score++
}
for _, ch := range file.Chapters {
if ch.Title != "" && ch.EndSeconds > ch.StartSeconds {
score++
break
}
}
switch {
case score >= 4:
return "high"
case score > 0:
return "medium"
default:
return "low"
}
}
+235
View File
@@ -0,0 +1,235 @@
package scanner
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeRootContentFinder struct {
contentID string
err error
calls int
}
func (f *fakeRootContentFinder) FindContentIDByRootPath(context.Context, int, string, string) (string, error) {
f.calls++
return f.contentID, f.err
}
type fakeFilesystemItemWriter struct {
upserts []*models.MediaItem
}
func (f *fakeFilesystemItemWriter) Upsert(_ context.Context, item *models.MediaItem) error {
cp := *item
f.upserts = append(f.upserts, &cp)
return nil
}
func TestParseAudiobookFolderSingleM4B(t *testing.T) {
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
if _, err := exec.LookPath(ffprobePath); err != nil {
ffprobePath = "ffprobe"
if _, err := exec.LookPath(ffprobePath); err != nil {
t.Skip("ffprobe not available")
}
}
ctx := context.Background()
got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/single_book")
if err != nil {
t.Fatalf("parseAudiobookFolder: %v", err)
}
if got.Title != "Test Audiobook" {
t.Errorf("Title = %q, want %q", got.Title, "Test Audiobook")
}
if got.Author != "Test Author" {
t.Errorf("Author = %q, want %q", got.Author, "Test Author")
}
if got.Series != "Test Series" {
t.Errorf("Series = %q, want %q", got.Series, "Test Series")
}
if got.Year != 2024 {
t.Errorf("Year = %d, want 2024", got.Year)
}
if len(got.Files) != 1 {
t.Fatalf("got %d files, want 1", len(got.Files))
}
if len(got.Files[0].Chapters) != 2 {
t.Errorf("file 0 chapters = %d, want 2", len(got.Files[0].Chapters))
}
}
func TestParseAudiobookFolderMultiFile(t *testing.T) {
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
if _, err := exec.LookPath(ffprobePath); err != nil {
ffprobePath = "ffprobe"
if _, err := exec.LookPath(ffprobePath); err != nil {
t.Skip("ffprobe not available")
}
}
ctx := context.Background()
got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/multi_file")
if err != nil {
t.Fatalf("parseAudiobookFolder: %v", err)
}
if got.Title != "Multi File Test" {
t.Errorf("Title = %q, want %q", got.Title, "Multi File Test")
}
if got.Author != "Multi Author" {
t.Errorf("Author = %q, want %q", got.Author, "Multi Author")
}
if got.Year != 2023 {
t.Errorf("Year = %d, want 2023", got.Year)
}
if len(got.Files) != 3 {
t.Fatalf("got %d files, want 3", len(got.Files))
}
for i, f := range got.Files {
if len(f.Chapters) != 1 {
t.Errorf("file %d: %d synthesized chapters, want 1", i, len(f.Chapters))
continue
}
wantStem := fmt.Sprintf("part%d", i+1)
if f.Chapters[0].Title != wantStem {
t.Errorf("file %d: chapter title = %q, want %q", i, f.Chapters[0].Title, wantStem)
}
if f.Chapters[0].StartSeconds != 0 || f.Chapters[0].EndSeconds != 0 {
t.Errorf("file %d: synthesized chapter range = %.3f..%.3f, want 0..0 unknown duration", i, f.Chapters[0].StartSeconds, f.Chapters[0].EndSeconds)
}
}
}
func TestAudiobookIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
book := &parsedAudiobook{Title: "Tagged Book", Author: "Author", Narrator: "Narrator", Year: 2024}
file := parsedAudiobookFile{Chapters: []ChapterInfo{{Title: "One", StartSeconds: 0, EndSeconds: 10}}}
if got := audiobookIdentityConfidence(book, file); got != "high" {
t.Fatalf("complete metadata confidence = %q, want high", got)
}
book = &parsedAudiobook{Title: "Tagged Book"}
file = parsedAudiobookFile{Chapters: []ChapterInfo{{Title: "Unknown duration"}}}
if got := audiobookIdentityConfidence(book, file); got != "medium" {
t.Fatalf("partial metadata confidence = %q, want medium", got)
}
book = &parsedAudiobook{}
file = parsedAudiobookFile{}
if got := audiobookIdentityConfidence(book, file); got != "low" {
t.Fatalf("empty metadata confidence = %q, want low", got)
}
}
func TestScanAudiobookFolderReturnsErrorWhenEveryReconcileFails(t *testing.T) {
root := t.TempDir()
bookDir := filepath.Join(root, "bad-book")
if err := os.Mkdir(bookDir, 0o755); err != nil {
t.Fatalf("mkdir book dir: %v", err)
}
if err := os.WriteFile(filepath.Join(bookDir, "chapter.mp3"), []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
err := s.ScanAudiobookFolder(context.Background(), &models.MediaFolder{ID: 42, Paths: []string{root}})
if err == nil {
t.Fatal("ScanAudiobookFolder returned nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "folder_id=42") {
t.Fatalf("error = %q, want folder id", err)
}
}
func TestScanAudiobookFolderReturnsCanceledContext(t *testing.T) {
root := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := &Scanner{}
err := s.ScanAudiobookFolder(ctx, &models.MediaFolder{ID: 42, Paths: []string{root}})
if !errors.Is(err, context.Canceled) {
t.Fatalf("ScanAudiobookFolder error = %v, want context.Canceled", err)
}
}
func TestResolveAudiobookMediaItemReusesRootScopedContentID(t *testing.T) {
finder := &fakeRootContentFinder{contentID: "book-root-id"}
writer := &fakeFilesystemItemWriter{}
got, err := resolveAudiobookMediaItem(
context.Background(),
finder,
writer,
7,
"/library/Author/Same Title",
&parsedAudiobook{Title: "Same Title", Year: 0, Author: "Author A"},
)
if err != nil {
t.Fatalf("resolveAudiobookMediaItem: %v", err)
}
if got != "book-root-id" {
t.Fatalf("contentID = %q, want root-scoped id", got)
}
if finder.calls != 1 {
t.Fatalf("root finder calls = %d, want 1", finder.calls)
}
if len(writer.upserts) != 0 {
t.Fatalf("unexpected item upsert for existing root: %d", len(writer.upserts))
}
}
func TestResolveAudiobookMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
finder := &fakeRootContentFinder{}
writer := &fakeFilesystemItemWriter{}
got, err := resolveAudiobookMediaItem(
context.Background(),
finder,
writer,
7,
"/library/Other Author/Same Title",
&parsedAudiobook{Title: "Same Title", Year: 0, Author: "Author B"},
)
if err != nil {
t.Fatalf("resolveAudiobookMediaItem: %v", err)
}
if got == "" {
t.Fatal("contentID is empty")
}
if len(writer.upserts) != 1 {
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
}
if writer.upserts[0].ContentID != got || writer.upserts[0].Type != "audiobook" {
t.Fatalf("upserted item = %+v, contentID %q", writer.upserts[0], got)
}
}
func TestResolveAudiobookMediaItemPropagatesRootLookupError(t *testing.T) {
wantErr := errors.New("root lookup failed")
finder := &fakeRootContentFinder{err: wantErr}
writer := &fakeFilesystemItemWriter{}
_, err := resolveAudiobookMediaItem(
context.Background(),
finder,
writer,
7,
"/library/Author/Book",
&parsedAudiobook{Title: "Book"},
)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if len(writer.upserts) != 0 {
t.Fatalf("upserts = %d, want 0", len(writer.upserts))
}
}
+97
View File
@@ -0,0 +1,97 @@
package scanner
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
)
// parsedPodcastShow is what parsePodcastShow returns. The scanner write
// path converts this into media_items (type='podcast') + episodes +
// media_files rows.
type parsedPodcastShow struct {
Title string
Author string
Year int
Episodes []parsedPodcastEpisode
}
type parsedPodcastEpisode struct {
Path string
Title string
Track int
}
// parsePodcastShow walks a single subdirectory of a podcast library and
// returns the show's metadata + episode list. Each audio file inside
// becomes one episode; tags are read from each file individually so
// per-episode titles surface correctly.
//
// Returns an os.ErrNotExist-wrapped error if the folder contains zero
// audio files.
func parsePodcastShow(ctx context.Context, ffprobePath string, folderPath string) (*parsedPodcastShow, error) {
entries, err := os.ReadDir(folderPath)
if err != nil {
return nil, fmt.Errorf("read podcast folder %s: %w", folderPath, err)
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if SupportsAudioFile(entry.Name()) {
audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name()))
}
}
if len(audioFiles) == 0 {
return nil, fmt.Errorf("podcast show %s: %w", folderPath, os.ErrNotExist)
}
sort.Strings(audioFiles)
show := &parsedPodcastShow{}
for idx, path := range audioFiles {
probed, err := ProbeFile(ctx, ffprobePath, path)
if err != nil {
return nil, fmt.Errorf("probe podcast file %s: %w", path, err)
}
tags := probed.FormatTags
if show.Title == "" {
show.Title = firstNonEmpty(tags["album"], tags["show"], filepath.Base(folderPath))
show.Author = firstNonEmpty(tags["artist"], tags["album_artist"])
if year := firstNonEmpty(tags["date"], tags["year"]); year != "" {
if y := parseTagYear(year); y > 0 {
show.Year = y
}
}
}
stem := filepath.Base(path)
stem = stem[:len(stem)-len(filepath.Ext(stem))]
track := idx + 1
if t := tags["track"]; t != "" {
if parsed := parseTrackNumber(t); parsed > 0 {
track = parsed
}
}
show.Episodes = append(show.Episodes, parsedPodcastEpisode{
Path: path,
Title: firstNonEmpty(tags["title"], stem),
Track: track,
})
}
return show, nil
}
// parseTrackNumber accepts ID3-style track values which may be "5" or
// "5/12". Returns 0 if no leading integer is present.
func parseTrackNumber(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
return n
}
+243
View File
@@ -0,0 +1,243 @@
package scanner
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/titleutil"
)
// ScanPodcastFolder walks a podcasts-typed media folder and writes one
// media_items row (type='podcast') per immediate subdirectory it can parse
// as a podcast show, plus one episodes row and one media_files row per
// audio file inside.
//
// RSS-subscribed feeds (podcast_feeds table) are handled by sub-plan 5;
// this method covers filesystem-only ingestion.
func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFolder) error {
if s == nil || folder == nil {
return fmt.Errorf("ScanPodcastFolder: nil scanner or folder")
}
var attempted int
var succeeded int
var failures []error
for _, root := range folder.Paths {
if err := ctx.Err(); err != nil {
return err
}
entries, err := os.ReadDir(root)
if err != nil {
slog.Warn("podcast scan: read root failed", "root", root, "error", err)
attempted++
failures = append(failures, fmt.Errorf("read root %s: %w", root, err))
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subPath := filepath.Join(root, entry.Name())
if err := ctx.Err(); err != nil {
return err
}
attempted++
if err := s.reconcilePodcastShow(ctx, folder, subPath); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
slog.Warn("podcast scan: show failed",
"folder_id", folder.ID,
"path", subPath,
"error", err,
)
failures = append(failures, fmt.Errorf("%s: %w", subPath, err))
// Continue with siblings — one bad show should not stop the scan.
continue
}
succeeded++
}
}
if attempted > 0 && succeeded == 0 && len(failures) > 0 {
return fmt.Errorf("podcast scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...))
}
return nil
}
func (s *Scanner) reconcilePodcastShow(ctx context.Context, folder *models.MediaFolder, folderPath string) error {
parsed, err := parsePodcastShow(ctx, s.ffprobePath, folderPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("parse podcast show %s: %w", folderPath, err)
}
showContentID, err := s.upsertPodcastMediaItem(ctx, folder.ID, folderPath, parsed)
if err != nil {
return fmt.Errorf("upsert podcast item: %w", err)
}
if err := s.upsertPodcastEpisodesAndFiles(ctx, folder, showContentID, folderPath, parsed); err != nil {
return fmt.Errorf("upsert podcast episodes+files: %w", err)
}
slog.Info("podcast scan: indexed",
"folder_id", folder.ID,
"content_id", showContentID,
"title", parsed.Title,
"author", parsed.Author,
"episodes", len(parsed.Episodes),
)
return nil
}
// upsertPodcastMediaItem reuses an item already linked to the same filesystem
// root, or creates a new row. It intentionally avoids title/year-only dedupe
// because filesystem podcasts can have duplicate titles and missing years.
// Returns the content_id used.
func (s *Scanner) upsertPodcastMediaItem(ctx context.Context, folderID int, folderPath string, show *parsedPodcastShow) (string, error) {
if s.itemRepo == nil {
return "", fmt.Errorf("itemRepo not configured on Scanner")
}
if s.fileRepo == nil {
return "", fmt.Errorf("fileRepo not configured on Scanner")
}
return resolvePodcastMediaItem(ctx, s.fileRepo, s.itemRepo, folderID, folderPath, show)
}
func resolvePodcastMediaItem(
ctx context.Context,
rootFinder filesystemRootContentFinder,
itemWriter filesystemMediaItemWriter,
folderID int,
folderPath string,
show *parsedPodcastShow,
) (string, error) {
if rootFinder == nil {
return "", fmt.Errorf("root content finder not configured")
}
if itemWriter == nil {
return "", fmt.Errorf("media item writer not configured")
}
existingID, err := rootFinder.FindContentIDByRootPath(ctx, folderID, folderPath, "podcast")
if err != nil {
return "", fmt.Errorf("find podcast by root path: %w", err)
}
if existingID != "" {
return existingID, nil
}
id, err := idgen.NextID()
if err != nil {
return "", fmt.Errorf("generate content_id: %w", err)
}
item := &models.MediaItem{
ContentID: id,
Type: "podcast",
Title: show.Title,
SortTitle: titleutil.DeriveDefaultSortTitle(show.Title),
Year: show.Year,
}
if err := itemWriter.Upsert(ctx, item); err != nil {
return "", err
}
return id, nil
}
// upsertPodcastEpisodesAndFiles writes one episodes row and one media_files
// row per episode in the parsed show. The episode's content_id is linked back
// through media_files.episode_id so playback and library queries can resolve
// it.
//
// Podcast episodes use season_number=0 (no season concept) and
// episode_number=track.
func (s *Scanner) upsertPodcastEpisodesAndFiles(
ctx context.Context,
folder *models.MediaFolder,
showContentID string,
folderPath string,
show *parsedPodcastShow,
) error {
if s.episodeRepo == nil {
return fmt.Errorf("episodeRepo not configured on Scanner")
}
for _, ep := range show.Episodes {
episodeID, err := idgen.NextID()
if err != nil {
return fmt.Errorf("generate episode content_id: %w", err)
}
episode := &models.Episode{
ContentID: episodeID,
SeriesID: showContentID,
SeasonNumber: 0,
EpisodeNumber: ep.Track,
Title: ep.Title,
}
// Upsert writes back the stored content_id (preserving existing on conflict).
if err := s.episodeRepo.Upsert(ctx, episode); err != nil {
return fmt.Errorf("upsert episode %q: %w", ep.Title, err)
}
mf := models.MediaFile{
ContentID: showContentID,
EpisodeID: episode.ContentID,
SeasonNumber: 0,
EpisodeNumber: ep.Track,
MediaFolderID: folder.ID,
CanonicalRootPath: folderPath,
ObservedRootPath: folderPath,
ContentGroupKey: showContentID,
GroupKeyVersion: 1,
BaseTitle: show.Title,
BaseYear: show.Year,
BaseType: "podcast",
IdentityConfidence: podcastIdentityConfidence(show, ep),
FilePath: ep.Path,
ProbeSource: "local",
}
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
return fmt.Errorf("upsert media file %s: %w", ep.Path, err)
}
}
return nil
}
func podcastIdentityConfidence(show *parsedPodcastShow, episode parsedPodcastEpisode) string {
if show == nil {
return "low"
}
score := 0
if show.Title != "" {
score++
}
if show.Author != "" {
score++
}
if show.Year > 0 {
score++
}
if episode.Title != "" {
score++
}
if episode.Track > 0 {
score++
}
switch {
case score >= 5:
return "high"
case score > 0:
return "medium"
default:
return "low"
}
}
+169
View File
@@ -0,0 +1,169 @@
package scanner
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestParsePodcastShow(t *testing.T) {
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
if _, err := exec.LookPath(ffprobePath); err != nil {
ffprobePath = "ffprobe"
if _, err := exec.LookPath(ffprobePath); err != nil {
t.Skip("ffprobe not available")
}
}
ctx := context.Background()
got, err := parsePodcastShow(ctx, ffprobePath, "testdata/podcast_fixtures/show_a")
if err != nil {
t.Fatalf("parsePodcastShow: %v", err)
}
if got.Title != "Show A" {
t.Errorf("Title = %q, want %q", got.Title, "Show A")
}
if got.Author != "Show A Host" {
t.Errorf("Author = %q, want %q", got.Author, "Show A Host")
}
if got.Year != 2024 {
t.Errorf("Year = %d, want 2024", got.Year)
}
if len(got.Episodes) != 3 {
t.Fatalf("got %d episodes, want 3", len(got.Episodes))
}
for i, ep := range got.Episodes {
wantTrack := i + 1
if ep.Track != wantTrack {
t.Errorf("episode %d Track = %d, want %d", i, ep.Track, wantTrack)
}
}
}
func TestPodcastIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
show := &parsedPodcastShow{Title: "Tagged Show", Author: "Host", Year: 2024}
episode := parsedPodcastEpisode{Title: "Episode", Track: 3}
if got := podcastIdentityConfidence(show, episode); got != "high" {
t.Fatalf("complete metadata confidence = %q, want high", got)
}
show = &parsedPodcastShow{Title: "Tagged Show"}
episode = parsedPodcastEpisode{Title: "Episode"}
if got := podcastIdentityConfidence(show, episode); got != "medium" {
t.Fatalf("partial metadata confidence = %q, want medium", got)
}
show = &parsedPodcastShow{}
episode = parsedPodcastEpisode{}
if got := podcastIdentityConfidence(show, episode); got != "low" {
t.Fatalf("empty metadata confidence = %q, want low", got)
}
}
func TestScanPodcastFolderReturnsErrorWhenEveryReconcileFails(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "bad-show")
if err := os.Mkdir(showDir, 0o755); err != nil {
t.Fatalf("mkdir show dir: %v", err)
}
if err := os.WriteFile(filepath.Join(showDir, "episode.mp3"), []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
err := s.ScanPodcastFolder(context.Background(), &models.MediaFolder{ID: 43, Paths: []string{root}})
if err == nil {
t.Fatal("ScanPodcastFolder returned nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "folder_id=43") {
t.Fatalf("error = %q, want folder id", err)
}
}
func TestScanPodcastFolderReturnsCanceledContext(t *testing.T) {
root := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := &Scanner{}
err := s.ScanPodcastFolder(ctx, &models.MediaFolder{ID: 43, Paths: []string{root}})
if !errors.Is(err, context.Canceled) {
t.Fatalf("ScanPodcastFolder error = %v, want context.Canceled", err)
}
}
func TestResolvePodcastMediaItemReusesRootScopedContentID(t *testing.T) {
finder := &fakeRootContentFinder{contentID: "podcast-root-id"}
writer := &fakeFilesystemItemWriter{}
got, err := resolvePodcastMediaItem(
context.Background(),
finder,
writer,
8,
"/library/Same Show",
&parsedPodcastShow{Title: "Same Show", Year: 0, Author: "Host A"},
)
if err != nil {
t.Fatalf("resolvePodcastMediaItem: %v", err)
}
if got != "podcast-root-id" {
t.Fatalf("contentID = %q, want root-scoped id", got)
}
if len(writer.upserts) != 0 {
t.Fatalf("unexpected item upsert for existing root: %d", len(writer.upserts))
}
}
func TestResolvePodcastMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
finder := &fakeRootContentFinder{}
writer := &fakeFilesystemItemWriter{}
got, err := resolvePodcastMediaItem(
context.Background(),
finder,
writer,
8,
"/library/Another Same Show",
&parsedPodcastShow{Title: "Same Show", Year: 0, Author: "Host B"},
)
if err != nil {
t.Fatalf("resolvePodcastMediaItem: %v", err)
}
if got == "" {
t.Fatal("contentID is empty")
}
if len(writer.upserts) != 1 {
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
}
if writer.upserts[0].ContentID != got || writer.upserts[0].Type != "podcast" {
t.Fatalf("upserted item = %+v, contentID %q", writer.upserts[0], got)
}
}
func TestResolvePodcastMediaItemPropagatesRootLookupError(t *testing.T) {
wantErr := errors.New("root lookup failed")
finder := &fakeRootContentFinder{err: wantErr}
writer := &fakeFilesystemItemWriter{}
_, err := resolvePodcastMediaItem(
context.Background(),
finder,
writer,
8,
"/library/Show",
&parsedPodcastShow{Title: "Show"},
)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if len(writer.upserts) != 0 {
t.Fatalf("upserts = %d, want 0", len(writer.upserts))
}
}
+15
View File
@@ -233,6 +233,7 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
}
pd.Chapters = normalizeChapters(raw.Chapters, pd.Duration)
pd.FormatTags = normalizeFormatTags(raw.Format.Tags)
return pd
}
@@ -452,6 +453,20 @@ func isHDR(colorTransfer string) bool {
return strings.Contains(ct, "smpte2084") || strings.Contains(ct, "arib-std-b67")
}
// normalizeFormatTags lowercases tag keys so callers can look up
// "title", "artist", "album" without worrying about ffprobe's mixed-case
// output. Trims whitespace from values.
func normalizeFormatTags(raw map[string]string) map[string]string {
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
out[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
return out
}
// detectContainer maps ffprobe format names to common container names.
func detectContainer(formatName string) string {
// ffprobe format_name can contain multiple names separated by commas
+54
View File
@@ -0,0 +1,54 @@
package scanner
import (
"context"
"os/exec"
"testing"
)
func TestProbeFileAudiobookFixture(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available; skipping fixture-based test")
}
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
if _, err := exec.LookPath(ffprobePath); err != nil {
// Fall back to plain "ffprobe" on PATH
ffprobePath = "ffprobe"
if _, err := exec.LookPath(ffprobePath); err != nil {
t.Skip("ffprobe not available; skipping fixture-based test")
}
}
ctx := context.Background()
got, err := ProbeFile(ctx, ffprobePath, "testdata/audiobook_fixtures/single_book/book.m4b")
if err != nil {
t.Fatalf("ProbeFile: %v", err)
}
// Chapters
if len(got.Chapters) != 2 {
t.Errorf("Chapters = %d, want 2", len(got.Chapters))
} else {
if got.Chapters[0].Title != "Intro" {
t.Errorf("Chapters[0].Title = %q, want %q", got.Chapters[0].Title, "Intro")
}
if got.Chapters[1].Title != "Outro" {
t.Errorf("Chapters[1].Title = %q, want %q", got.Chapters[1].Title, "Outro")
}
}
// Format tags
if got.FormatTags == nil {
t.Fatal("FormatTags is nil")
}
if title := got.FormatTags["title"]; title != "Test Audiobook" {
t.Errorf("FormatTags[\"title\"] = %q, want %q", title, "Test Audiobook")
}
if artist := got.FormatTags["artist"]; artist != "Test Author" {
t.Errorf("FormatTags[\"artist\"] = %q, want %q", artist, "Test Author")
}
if album := got.FormatTags["album"]; album != "Test Series" {
t.Errorf("FormatTags[\"album\"] = %q, want %q", album, "Test Series")
}
}
+95 -15
View File
@@ -108,6 +108,9 @@ type Scanner struct {
folderRepo *catalog.FolderRepository
libraryRepo *catalog.LibraryItemRepository
episodeLibraryRepo *catalog.EpisodeLibraryRepository
itemRepo *catalog.ItemRepository
personRepo *catalog.PersonRepository
episodeRepo *catalog.EpisodeRepository
ffprobePath string
s3Client *s3client.Client // public assets bucket (may be nil)
workers int
@@ -157,6 +160,9 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client
folderRepo: catalog.NewFolderRepository(fileRepo.Pool()),
libraryRepo: catalog.NewLibraryItemRepository(fileRepo.Pool()),
episodeLibraryRepo: catalog.NewEpisodeLibraryRepository(fileRepo.Pool()),
itemRepo: catalog.NewItemRepository(fileRepo.Pool()),
personRepo: catalog.NewPersonRepository(fileRepo.Pool()),
episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()),
ffprobePath: ffprobePath,
s3Client: s3Client,
workers: workers,
@@ -192,9 +198,28 @@ func (s *Scanner) SetMetadataQueueProducer(producer MetadataQueueProducer) {
// ScanFolder walks a media folder's directory tree, discovers media files,
// probes them for technical data, and upserts them into the database.
// Files previously in the DB that no longer exist on disk are marked as missing.
//
// Audiobook libraries are handled by ScanAudiobookFolder and podcast
// libraries by ScanPodcastFolder; both bypass the per-file movie/TV
// pipeline entirely.
func (s *Scanner) ScanFolder(ctx context.Context, folder *models.MediaFolder) (*ScanResult, error) {
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
defer stopWatch()
if isAudiobookLibraryType(folder.Type) {
if err := s.ScanAudiobookFolder(watchCtx, folder); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
if isPodcastLibraryType(folder.Type) {
if err := s.ScanPodcastFolder(watchCtx, folder); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
return s.scanPaths(watchCtx, folder, folder.Paths, folder.Paths, true)
}
@@ -215,6 +240,61 @@ func isMovieLibraryType(libraryType string) bool {
return false
}
}
func isAudiobookLibraryType(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "audiobook", "audiobooks":
return true
default:
return false
}
}
func isPodcastLibraryType(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "podcast", "podcasts":
return true
default:
return false
}
}
// walkMode tells walkLogicalTree which file extensions to surface and
// which library-specific filename heuristics (sample/extra skipping)
// to apply.
type walkMode int
const (
walkModeVideo walkMode = iota // bare video walk: video extensions, no movie skipping
walkModeMovie // movie library: video extensions + sample/extra skipping
walkModeAudiobook // audiobook library: audio extensions, no skipping
walkModePodcast // podcast library: audio extensions, no skipping
)
// walkModeFor derives a walkMode from a media_folders.type string.
// Unknown types default to walkModeVideo so existing call sites that
// pass arbitrary types preserve their prior behavior.
func walkModeFor(folderType string) walkMode {
switch {
case isMovieLibraryType(folderType):
return walkModeMovie
case isAudiobookLibraryType(folderType):
return walkModeAudiobook
case isPodcastLibraryType(folderType):
return walkModePodcast
default:
return walkModeVideo
}
}
// acceptsExt reports whether the given lowercased extension belongs to
// the file types this walk mode is looking for.
func (m walkMode) acceptsExt(ext string) bool {
switch m {
case walkModeAudiobook, walkModePodcast:
return audioExtensions[ext]
default:
return videoExtensions[ext]
}
}
func canonicalWalkPath(path string) (string, error) {
resolved, err := filepath.EvalSymlinks(path)
@@ -236,7 +316,7 @@ func walkLogicalTree(
ctx context.Context,
logicalPath string,
physicalPath string,
movieLibrary bool,
mode walkMode,
visitedPhysicalDirs map[string]struct{},
filePaths *[]string,
) error {
@@ -264,22 +344,22 @@ func walkLogicalTree(
return nil
}
if targetInfo.IsDir() {
return walkLogicalTree(ctx, logicalPath, resolved, movieLibrary, visitedPhysicalDirs, filePaths)
return walkLogicalTree(ctx, logicalPath, resolved, mode, visitedPhysicalDirs, filePaths)
}
if movieLibrary && shouldSkipMovieSupplementalFile(logicalPath) {
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) {
return nil
}
if videoExtensions[strings.ToLower(filepath.Ext(logicalPath))] {
if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) {
*filePaths = append(*filePaths, logicalPath)
}
return nil
}
if !info.IsDir() {
if movieLibrary && shouldSkipMovieSupplementalFile(logicalPath) {
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalPath) {
return nil
}
if videoExtensions[strings.ToLower(filepath.Ext(logicalPath))] {
if mode.acceptsExt(strings.ToLower(filepath.Ext(logicalPath))) {
*filePaths = append(*filePaths, logicalPath)
}
return nil
@@ -298,7 +378,7 @@ func walkLogicalTree(
if isIgnoredDirectoryPath(logicalPath) {
return nil
}
if movieLibrary && shouldSkipMovieSupplementalDir(logicalPath) {
if mode == walkModeMovie && shouldSkipMovieSupplementalDir(logicalPath) {
return nil
}
@@ -329,31 +409,31 @@ func walkLogicalTree(
continue
}
if targetInfo.IsDir() {
if err := walkLogicalTree(ctx, logicalChild, resolved, movieLibrary, visitedPhysicalDirs, filePaths); err != nil {
if err := walkLogicalTree(ctx, logicalChild, resolved, mode, visitedPhysicalDirs, filePaths); err != nil {
return err
}
continue
}
if movieLibrary && shouldSkipMovieSupplementalFile(logicalChild) {
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) {
continue
}
if videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
*filePaths = append(*filePaths, logicalChild)
}
continue
}
if entry.IsDir() {
if err := walkLogicalTree(ctx, logicalChild, physicalChild, movieLibrary, visitedPhysicalDirs, filePaths); err != nil {
if err := walkLogicalTree(ctx, logicalChild, physicalChild, mode, visitedPhysicalDirs, filePaths); err != nil {
return err
}
continue
}
if movieLibrary && shouldSkipMovieSupplementalFile(logicalChild) {
if mode == walkModeMovie && shouldSkipMovieSupplementalFile(logicalChild) {
continue
}
if videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
*filePaths = append(*filePaths, logicalChild)
}
}
@@ -364,7 +444,7 @@ func walkLogicalTree(
func collectLogicalFilePaths(ctx context.Context, walkRoots []string, libraryType string) ([]string, error) {
filePaths := make([]string, 0)
visitedPhysicalDirs := make(map[string]struct{})
movieLibrary := isMovieLibraryType(libraryType)
mode := walkModeFor(libraryType)
for _, rootPath := range walkRoots {
if ctx != nil {
@@ -376,7 +456,7 @@ func collectLogicalFilePaths(ctx context.Context, walkRoots []string, libraryTyp
if cleanRoot == "" || cleanRoot == "." {
continue
}
if err := walkLogicalTree(ctx, cleanRoot, cleanRoot, movieLibrary, visitedPhysicalDirs, &filePaths); err != nil {
if err := walkLogicalTree(ctx, cleanRoot, cleanRoot, mode, visitedPhysicalDirs, &filePaths); err != nil {
return nil, err
}
}
+40
View File
@@ -160,3 +160,43 @@ func testStringSliceContains(values []string, target string) bool {
}
return false
}
func TestIsAudiobookLibraryType(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"audiobooks", true},
{"audiobook", true},
{"Audiobook", true},
{" AUDIOBOOKS ", true},
{"movies", false},
{"series", false},
{"", false},
}
for _, tc := range cases {
if got := isAudiobookLibraryType(tc.in); got != tc.want {
t.Errorf("isAudiobookLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestIsPodcastLibraryType(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"podcasts", true},
{"podcast", true},
{"Podcast", true},
{" PODCASTS ", true},
{"series", false},
{"audiobooks", false},
{"", false},
}
for _, tc := range cases {
if got := isPodcastLibraryType(tc.in); got != tc.want {
t.Errorf("isPodcastLibraryType(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -33,6 +33,7 @@ type ProbeData struct {
AudioTracks []AudioTrackInfo
SubtitleTracks []SubtitleTrackInfo
Chapters []ChapterInfo
FormatTags map[string]string // format-level tags from ffprobe (title, artist, album, date, etc.)
}
// VideoTrackInfo describes a probed video track.
+37
View File
@@ -0,0 +1,37 @@
-- +goose Up
-- +goose StatementBegin
-- Audiobookshelf-compatible client sessions. Same role as
-- jellycompat_sessions (compat-layer token store) but with a proper
-- users FK, device metadata, and soft revocation via revoked_at instead
-- of hard expiry.
CREATE TABLE IF NOT EXISTS public.abs_sessions (
id bigserial PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
token_hash text NOT NULL,
device_id text NOT NULL,
device_name text,
client_name text,
client_version text,
created_at timestamp with time zone NOT NULL DEFAULT now(),
last_seen_at timestamp with time zone NOT NULL DEFAULT now(),
revoked_at timestamp with time zone
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_abs_sessions_token_hash
ON public.abs_sessions (token_hash);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_user_device
ON public.abs_sessions (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_last_seen
ON public.abs_sessions (last_seen_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS public.idx_abs_sessions_last_seen;
DROP INDEX IF EXISTS public.idx_abs_sessions_user_device;
DROP INDEX IF EXISTS public.idx_abs_sessions_token_hash;
DROP TABLE IF EXISTS public.abs_sessions;
-- +goose StatementEnd
+33
View File
@@ -0,0 +1,33 @@
-- +goose Up
-- +goose StatementBegin
-- RSS-feed metadata for subscribed podcasts. One row per podcast
-- media_items row; the feed refresher polls feed_url every
-- refresh_interval_seconds and upserts new episodes into the existing
-- episodes table.
CREATE TABLE IF NOT EXISTS public.podcast_feeds (
media_item_id text PRIMARY KEY
REFERENCES public.media_items(content_id) ON DELETE CASCADE,
feed_url text NOT NULL,
etag text,
last_modified text,
last_refreshed_at timestamp with time zone,
last_refresh_error text,
refresh_interval_seconds integer NOT NULL DEFAULT 600,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_podcast_feeds_feed_url
ON public.podcast_feeds (feed_url);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_due_for_refresh
ON public.podcast_feeds (last_refreshed_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS public.idx_podcast_feeds_due_for_refresh;
DROP INDEX IF EXISTS public.idx_podcast_feeds_feed_url;
DROP TABLE IF EXISTS public.podcast_feeds;
-- +goose StatementEnd
@@ -0,0 +1,29 @@
-- +goose Up
-- +goose StatementBegin
-- Intentionally a no-op migration.
--
-- The audiobooks absorption design spec assumed silo needed to add a
-- 'kind' column to a hypothetical media_libraries table to discriminate
-- between movies/tv/audiobooks/podcasts at the library level. Discovery
-- found that the actual table is media_folders and it already has a
-- type text NOT NULL column carrying that role (existing values:
-- 'movies', 'series', 'mixed').
--
-- There is no CHECK constraint or enum on media_folders.type, so adding
-- 'audiobooks' or 'podcasts' as future values requires no DDL — the
-- scanner branches in sub-plan 2 will simply read those values from
-- media_folders.type the same way they do for 'movies' and 'series'.
--
-- This migration is preserved as a numbered no-op so the audit trail
-- shows the decision rather than leaving a numbering gap. The matching
-- .down.sql is also a no-op.
SELECT 1;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- See 159_media_folders_kind_noop.up.sql — intentionally empty.
SELECT 1;
-- +goose StatementEnd
@@ -0,0 +1,14 @@
-- +goose Up
-- +goose StatementBegin
-- Master kill-switch for the absorbed audiobooks feature. Defaults to
-- 'false' so landing migrations 139-142 is a no-op for users; operators
-- flip this to 'true' at cutover.
INSERT INTO server_settings (key, value) VALUES ('audiobooks.enabled', 'false')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM server_settings WHERE key = 'audiobooks.enabled';
-- +goose StatementEnd
@@ -67,6 +67,10 @@ function contentLevelsForType(libraryType: string): string[] {
return ["movie"];
case "mixed":
return ["movie", "series", "season", "episode"];
case "audiobooks":
return ["audiobook"];
case "podcasts":
return ["podcast", "podcast_episode"];
default:
return [];
}
@@ -131,7 +135,10 @@ function buildLevelChainsFromServer(
}
function contentLevelLabel(level: string): string {
return level.charAt(0).toUpperCase() + level.slice(1);
return level
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function buildProviderChainBody(activeLevelChains: Record<string, LevelChainItem[]>) {
@@ -466,6 +473,8 @@ export function LibraryForm({
<SelectItem value="movies">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
<SelectItem value="audiobooks">Audiobooks</SelectItem>
<SelectItem value="podcasts">Podcasts</SelectItem>
</SelectContent>
</Select>
</div>