Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c5e6d1a1 | ||
|
|
0cd37aa618 | ||
|
|
4d894379df | ||
|
|
44fc0090c7 | ||
|
|
38c7fb44e1 | ||
|
|
c0222a55e1 | ||
|
|
ea6636f9d9 | ||
|
|
8c172ec9dd | ||
|
|
514b72cddb | ||
|
|
6983925b2e | ||
|
|
d897124e94 | ||
|
|
543534a058 | ||
|
|
234491f1cc | ||
|
|
cb0cb04534 | ||
|
|
d4dc695e1d | ||
|
|
f4b9d4cf38 | ||
|
|
fd41f56381 | ||
|
|
aef670c3bc | ||
|
|
3a43537e67 | ||
|
|
bc2b815391 | ||
|
|
7af0cf241e | ||
|
|
209fb50f16 | ||
|
|
a5a0fad86e | ||
|
|
9c6964e1a1 | ||
|
|
901285b225 | ||
|
|
620cd2746b | ||
|
|
355da1a4fd | ||
|
|
8deb18273d | ||
|
|
a8227723bf | ||
|
|
28ef3ac3c8 | ||
|
|
6d77e5a135 |
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user