feat(audiobooks): make audiobook libraries first-class catalog items (#73)

* docs(audiobooks): design spec for plugin absorption

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

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

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

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

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

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

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

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

* feat(audiobooks): migration 139 add abs_sessions table

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

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

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

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

Cosmetic only — the running schema is unchanged.

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

* feat(audiobooks): migration 140 add podcast_feeds table

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

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

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

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

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

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

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

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

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

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

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

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

* feat(audiobooks): scaffold internal/audiobooks package

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

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

* style(audiobooks): cosmetic cleanups in scaffolded package

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

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

No behavior change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(audiobooks): filesystem podcast scanner

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(audiobooks): address foundation review comments

* fix(audiobooks): tighten scanner identity handling

* fix(audiobooks): propagate scanner cancellation

* chore(audiobooks): adopt goose migration layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(audiobooks): detail page with chapter list

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

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

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

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

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

* feat(audiobooks): wire navigation and routes

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

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

* fix(audiobooks): address native API review comments

* feat(audiobooks): add ABS compatibility and polish

* fix(audiobooks): stabilize ABS playback progress reporting

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

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

* fix(audiobooks): align player seek bar props

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

* feat(admin): add server restart endpoint

* fix(audiobooks): address review comment findings

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-07 15:57:05 -04:00
committed by GitHub
co-authored by RXWatcher Claude Opus 4.7
parent c469550fa0
commit eb6024573e
253 changed files with 50703 additions and 446 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ RUN apt-get update && \
RUN mkdir -p /tmp/silo-transcode
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8096
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
ENTRYPOINT ["silo"]
+150 -1
View File
@@ -18,9 +18,12 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/hashicorp/go-hclog"
"github.com/jackc/pgx/v5/pgxpool"
@@ -34,6 +37,8 @@ import (
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/api/handlers"
"github.com/Silo-Server/silo-server/internal/audiobooks"
"github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/cache"
@@ -421,6 +426,8 @@ func main() {
appCtx, appCancel := context.WithCancel(ctx)
defer appCancel()
restartReqCh := make(chan struct{}, 1)
var restartRequested atomic.Bool
eventBus := cache.NewEventBus(cfg.Redis.URL)
logStreamHub := logstream.NewHub(nodeID, eventBus)
@@ -521,6 +528,19 @@ func main() {
OpsLogRepo: opsRepo,
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
RequestServerRestart: func(context.Context) error {
if !restartRequested.CompareAndSwap(false, true) {
return handlers.ErrServerRestartAlreadyRequested
}
restartReqCh <- struct{}{}
return nil
},
}
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
absCompatEnabled = false
}
adminJobCancelRegistry := adminjob.NewCancelRegistry()
deps.AdminJobCancelRegistry = adminJobCancelRegistry
@@ -868,6 +888,7 @@ func main() {
var groupClaimRepo *catalog.GroupClaimRepository
var seasonRepo *catalog.SeasonRepository
var episodeRepo *catalog.EpisodeRepository
var audiobookEnricher *audiobooks.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
@@ -938,6 +959,18 @@ func main() {
personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo)
personRefreshService.SetImageResolver(imageResolver)
// Wire the audiobook enricher. It uses the same plugin resolver and chain
// repo as the movie/TV pipeline, but resolves providers at
// content_level='audiobook' and sweeps items directly rather than via a queue.
audiobookEnricher = audiobooks.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
// Always wire the image resolver so plugin-prefixed URLs (e.g.
// metadb://) can be resolved to presigned HTTP URLs in API responses.
metadataService.SetImageResolver(imageResolver)
@@ -948,10 +981,17 @@ func main() {
imageCacher := imagecache.New(deps.S3Public)
metadataService.SetImageCacher(imageCacher)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
if deps.Scanner != nil {
deps.Scanner.SetImageCacher(imageCacher)
}
if cfg.Metadata.CacheImages {
personRefreshService.SetImageCacher(imageCacher)
slog.Info("metadata image caching enabled")
}
if audiobookEnricher != nil {
audiobookEnricher.SetImageCacher(imageCacher)
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
}
}
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
@@ -1487,6 +1527,10 @@ func main() {
historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver)
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
if audiobookEnricher != nil {
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
if err != nil {
@@ -1503,6 +1547,57 @@ func main() {
slog.Info("task manager started")
}
// Build the ABS-compatible REST + Socket.io handler when a DB pool is
// available. Routes are mounted at the root level by NewRouter (not under
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
// /abs/socket.io/* without path prefix hacks.
if absCompatEnabled && deps.DB != nil {
absUserRepo := auth.NewUserRepository(deps.DB)
absSessionRepo := auth.NewSessionRepository(deps.DB)
absJWTService := auth.NewJWTService(
cfg.Auth.JWTSecret,
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
absAuthSvc := auth.NewService(
auth.NewLocalProvider(absUserRepo, absSessionRepo),
absJWTService,
absSessionRepo,
absUserRepo,
nil, // invite codes: not needed for ABS compat
nil, // settings: not needed here
nil, // user store: not needed here
)
absItemRepo := catalog.NewItemRepository(deps.DB)
absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
absSeasonRepo := catalog.NewSeasonRepository(deps.DB)
absPersonRepo := catalog.NewPersonRepository(deps.DB)
var absFileFetcher catalog.FileVersionFetcher
if deps.FileRepo != nil {
absFileFetcher = deps.FileRepo
}
absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher)
if deps.ImageResolver != nil {
absDetailSvc.SetImageResolver(deps.ImageResolver)
}
absHDeps := audiobooks.ABSHandlerDeps{
Pool: deps.DB,
Items: absItemRepo,
Files: deps.FileRepo,
Settings: settingsRepo,
Auth: &audiobooks.SiloCredValidator{
Auth: absAuthSvc,
Pool: deps.DB,
},
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
Recs: recommendations.NewRepo(deps.DB),
Detail: absDetailSvc,
}
absH := audiobooksService.BuildABSHandler(absHDeps)
deps.ABSHandler = absH
}
_ = audiobooksService
if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil {
userRepo := auth.NewUserRepository(deps.DB)
sessionRepo := auth.NewSessionRepository(deps.DB)
@@ -1624,6 +1719,11 @@ func main() {
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", promhttp.Handler())
metricsMux.Handle("/api/", router)
// ABS-compat is NOT mounted on the main listener — see the "ABS compat
// listener" block below. It binds its own port so the discovery probes
// (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL
// space without collision with silo's SPA fallback. Mirrors how the
// Jellyfin compat server is set up at :8096.
metricsMux.Handle("/", server.FrontendHandler())
// Step 9: Start background workers (if needed).
@@ -1846,6 +1946,28 @@ func main() {
compatSrv.IdleTimeout = 120 * time.Second
}
// ABS-compat listener — dedicated http.Server bound to its own port
// (default :13378) that hosts the Audiobookshelf-compatible API.
// Mirrors the Jellyfin compat layout above. The ABS handler mounts
// onto a fresh chi router here so /ping, /healthcheck, /status, /login,
// /socket.io, etc. own the URL space at the root — no SPA fallback,
// no collision with silo's /api/v1.
var absSrv *http.Server
if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" {
absRouter := chi.NewRouter()
absRouter.Use(chimiddleware.Recoverer)
absRouter.Use(chimiddleware.Compress(5))
deps.ABSHandler.Mount(absRouter)
absSrv = &http.Server{
Addr: cfg.AudiobookshelfCompat.Listen,
Handler: absRouter,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
}
}
// Run non-critical startup work in the background so it doesn't delay the
// HTTP listener from accepting connections. Steps run sequentially and stop
// early if the app context is cancelled (shutdown).
@@ -1870,7 +1992,7 @@ func main() {
}()
}
errCh := make(chan error, 2)
errCh := make(chan error, 3)
go func() {
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
@@ -1885,6 +2007,14 @@ func main() {
}
}()
}
if absSrv != nil {
go func() {
slog.Info("ABS compat server listening", "addr", absSrv.Addr)
if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
errCh <- fmt.Errorf("abs compat server error: %w", listenErr)
}
}()
}
// Step 11: Wait for termination signal.
sigCh := make(chan os.Signal, 1)
@@ -1895,6 +2025,9 @@ func main() {
case sig := <-sigCh:
appCancel()
slog.Info("received signal, shutting down", "signal", sig)
case <-restartReqCh:
appCancel()
slog.Info("server restart requested, shutting down")
case serverErr := <-errCh:
appCancel()
slog.Error("server error, shutting down", "error", serverErr)
@@ -1914,6 +2047,11 @@ func main() {
slog.Error("jellyfin compat shutdown error", "error", shutdownErr)
}
}
if absSrv != nil {
if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil {
slog.Error("abs compat shutdown error", "error", shutdownErr)
}
}
// 2. Clean up stale sessions.
if sessionCleaner != nil {
@@ -2264,3 +2402,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)
}
+3
View File
@@ -43,11 +43,14 @@ services:
ports:
- "${PORT:-8090}:8080"
- "${JF_PORT:-8096}:8096"
- "${ABS_PORT:-13378}:13378"
volumes:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
- /proc/meminfo:/host/proc/meminfo:ro
depends_on:
postgres:
@@ -0,0 +1,138 @@
# ABS Wire-Shape Verification (post collections-unify cutover)
Breadcrumbs for the next person debugging an ABS endpoint wire-shape issue
after the canonical-tables cutover (migration 156 + commits `0dc830e`,
`8c7fe1b`, `b64ce17`).
The Go in-memory structs (`abs.Collection`, `abs.Playlist`,
`abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem`) have **no
`json:"..."` struct tags**. The JSON wire contract is defined entirely by
the `*ToABS()` map-builder helpers in `internal/audiobooks/abs/`. As long as
the store layer populates the struct fields with the same values, the wire
shape is preserved. The rewrites in `0dc830e`, `8c7fe1b`, `b64ce17` did NOT
modify the emitters — only the SQL-backed store implementations.
## Envelope tests — which one guards which endpoint
Run with `go test ./internal/audiobooks/abs/ -run Envelope -v -count=1`.
| Test file | Functions | Guards |
|---|---|---|
| `collections_envelope_test.go` | `TestCollectionEnvelope_HasRequiredKeys`, `TestCollectionListShape_OmitsBooks` | `collectionToABS` keys; list-shape (no `books`) vs detail-shape (with `books`) for `GET /api/collections`, `GET /api/collections/{id}`, `GET /api/libraries/{id}/collections`, and all POST/PATCH/DELETE collection endpoints |
| `playlists_envelope_test.go` | `TestPlaylistEnvelope_HasRequiredKeys`, `TestPlaylistEnvelope_OmitsCoverPathWhenEmpty`, `TestPlaylistListShape_OmitsItems` | `playlistToABS` keys; list-shape (no `items`) vs detail-shape (with `items`); `coverPath` is omitted when `CoverItem == ""` — covers `GET /api/playlists`, `GET /api/playlists/{id}`, `GET /api/libraries/{id}/playlists`, batch and item-add/remove endpoints |
| `smart_collections_envelope_test.go` | `TestSmartCollectionEnvelope_HasRequiredKeys`, `TestSmartCollectionEnvelope_EmptyQueryDef` | `smartCollectionToABS` keys; `queryDef` decoded from raw JSONB bytes into nested object, empty bytes → `{}` — covers `GET /api/me/smart-collections`, `GET /api/me/smart-collections/{id}`, POST/PATCH equivalents |
| `bookmarks_envelope_test.go` | `TestBookmarkEnvelope_HasRequiredKeys` | Bookmarks emitter (separate from this cutover, not affected by migration 156) |
| `login_envelope_test.go` | `TestLoginEnvelope_HasRequiredKeys` and three xReturnTokens / displayName variants | Login envelope (not affected by migration 156) |
In addition, handler-level round-trip tests live in
`playlists_handler_test.go` and `bookmarks_handler_test.go`. There is NO
snapshot/goldenfile harness in the repo today — these envelope tests are
the primary regression guard.
## Manual live-DB diff procedure
For a pre/post-deploy wire-shape verification against a live silo, see the
plan's Task 5 "manual verification" section at
`docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md`
(§ `Task 5: Wire-shape regression test`). Summary:
1. Pre-cutover, seed one of each (collection, playlist with item,
smart collection) via the old `abs_*` tables, then capture each list
endpoint's response to `/tmp/wire_before_<kind>.json` using a curl
against the running silo with a valid ABS bearer token (HS256 JWT —
minted by the login flow, NOT the raw `abs_sessions.token` value).
2. Apply migration 156. Seed equivalent rows in `user_personal_collections`
with the same IDs and content. Capture again to
`/tmp/wire_after_<kind>.json`.
3. `diff /tmp/wire_before_<kind>.json /tmp/wire_after_<kind>.json` for each
`kind in {collections,playlists,smart_collections}`. Expected: empty
diff.
This is an MR-description-level manual step, not a committed test.
## Intentionally-zero fields after the rewrite
These wire keys are still emitted, but the store always populates the
in-memory field with the zero value because the canonical
`user_personal_collections` schema has no analog column (per spec §6 of the
collections-unify plan). They are NOT bugs — do not "fix" them by reaching
for some other column.
| In-memory field | Wire key | Zero value | Spec ref | Disposition |
|---|---|---|---|---|
| `abs.Playlist.CoverItem` | `coverPath` | `""` (key omitted entirely when empty — see `playlistToABS`) | spec §6.1 | Dropped. PATCH `coverPath` body field is silently ignored by the store. Cover regeneration from first-item poster is the chosen long-term path. |
| `abs.SmartCollection.Color` | `color` | `""` (key always emitted as empty string) | spec §6.3 | Deferred. No column on `user_personal_collections`. Wire key stays present for client compatibility. |
| `abs.SmartCollection.IsPinned` | `isPinned` | `false` (key always emitted) | spec §6.2 | Deferred. Same rationale. |
If you're adding a "Pin this smart collection" feature later, the column
needs to land in a new migration on `user_personal_collections` first;
don't try to thread it through some adjacent column.
## Canonical mapping — struct field → source column
The full pre-cutover wire contract was captured in a working note that does
not persist (`/tmp/abs_wire_contract.md`). The essentials are reproduced
here so the next maintainer doesn't have to re-derive them.
All three struct families now read from `user_personal_collections`
(and `user_personal_collection_items` for collections + playlists),
discriminated by `collection_type IN ('manual','playlist','smart')`.
### `abs.Collection` (`collection_type = 'manual'`)
| Field | Source column |
|---|---|
| ID | `user_personal_collections.id` |
| UserID | `user_personal_collections.user_id::text` (column is `integer`) |
| ProfileID | `user_personal_collections.profile_id` |
| Name | `user_personal_collections.name` |
| Description | `user_personal_collections.description` |
| IsPublic | `user_personal_collections.is_shared` |
| CreatedAt | `user_personal_collections.created_at` |
| UpdatedAt | `user_personal_collections.updated_at` |
`abs.CollectionItem` reads `user_personal_collection_items` with
`sub_item_id = ''` filter (the manual-collection sentinel established in
migration 156 step 1). LibraryItemID ← `media_item_id`. ORDER BY
`added_at ASC`.
### `abs.Playlist` (`collection_type = 'playlist'`)
Same column mapping as `abs.Collection` (modulo `collection_type` filter)
EXCEPT `CoverItem` which is always `""` — see "Intentionally-zero fields"
above.
`abs.PlaylistItem` reads `user_personal_collection_items` with NO
`sub_item_id` filter (playlists can carry episode entries). Mapping:
LibraryItemID ← `media_item_id`, EpisodeID ← `sub_item_id`,
Position ← `position`. ORDER BY `position ASC, added_at ASC`.
### `abs.SmartCollection` (`collection_type = 'smart'`)
Same column mapping as `abs.Collection` EXCEPT:
- `Color`, `IsPinned` → always zero (see above).
- `QueryDef` ← `user_personal_collections.query_definition` (JSONB → `[]byte`
round-trip; column is `NOT NULL DEFAULT '{}'::jsonb` per migration 016).
No items table — smart-collection membership is evaluated at request time
via the `smartcoll` package.
### Wire-shape quirks worth remembering
- Collection/Playlist emit `lastUpdate` (NOT `updatedAt`). SmartCollection
emits `updatedAt`. Cross-struct inconsistency, carry forward verbatim.
- All timestamps are `UnixMilli()` int64, NOT RFC3339 strings.
- `ProfileID` is carried in memory but NEVER emitted on the wire — it's
scope/auth only.
- The list vs detail shape distinction is implicit: list responses pass
`nil` for the items/books slice; the emitter then omits the key
entirely. Clients differentiate on key presence.
## Verification status (2026-05-27)
- All 13 envelope tests pass (run: `go test ./internal/audiobooks/abs/
-run Envelope -v -count=1`).
- Full audiobooks test suite passes (`go test ./internal/audiobooks/...
-short -count=1 -timeout 120s`).
- Live-DB diff was NOT executed in CI — see manual procedure above.
File diff suppressed because it is too large Load Diff
@@ -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,326 @@
# Audiobooks Absorption — Sub-plan 3: Silo-native API + Frontend (MVP)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Land the minimum viable silo-native audiobooks UI: backend REST endpoints + React pages so a user can browse audiobook libraries scanned by sub-plan 2, see book details with chapters, play with progress saved. Author/series index pages, smart collections, share links, and other features from the spec's "nice-to-haves" list are deferred to a future sub-plan if the demand surfaces.
**Architecture:** Three new Go handler files under `internal/api/handlers/audiobooks_*.go` for list/detail/progress. New `web/src/pages/audiobooks/` directory with three pages: Library, Detail, and an inline player. Hooks under `web/src/hooks/audiobooks/`. The streaming path reuses silo's existing `/api/v1/stream/{session_id}` endpoint — no new transcode code.
**Tech stack:** Go + chi router + pgx for backend; React 19 + TanStack Query + radix-ui + tailwind for frontend.
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
**Predecessor:** `docs/superpowers/plans/2026-05-24-audiobooks-absorption-2-scanner.md` (scanner produces `media_items.type='audiobook'`)
---
## File Structure
| Path | C/M | Purpose |
|---|---|---|
| `internal/api/handlers/audiobooks_list.go` | C | `HandleListAudiobooks` — GET /api/v1/audiobooks (paginated, filterable by library) |
| `internal/api/handlers/audiobooks_detail.go` | C | `HandleGetAudiobook` — GET /api/v1/audiobooks/{id} (returns item + chapters + author/narrator + listening progress) |
| `internal/api/handlers/audiobooks_progress.go` | C | `HandleReportAudiobookProgress` — POST /api/v1/audiobooks/{id}/progress |
| `internal/api/router.go` | M | Mount three new routes under `/api/v1/audiobooks/*` |
| `web/src/hooks/audiobooks/useAudiobookLibrary.ts` | C | TanStack Query hook for list |
| `web/src/hooks/audiobooks/useAudiobook.ts` | C | Detail + progress hooks |
| `web/src/pages/audiobooks/AudiobookLibrary.tsx` | C | Grid page (poster + title + author) |
| `web/src/pages/audiobooks/AudiobookDetail.tsx` | C | Detail page (cover, metadata, chapter list, play button) |
| `web/src/pages/audiobooks/AudiobookPlayer.tsx` | C | HTML5 `<audio>` player with chapter nav + progress reporting |
| `web/src/App.tsx` or routing entry | M | Add the three new routes |
| `web/src/components/Sidebar.tsx` (or equivalent) | M | Add "Audiobooks" navigation link when at least one audiobook library exists |
---
## Task 1: Backend — list audiobooks
GET /api/v1/audiobooks?library_id=N&limit=...&offset=... → paginated audiobook list scoped to libraries the user has access to.
**Files:** Create `internal/api/handlers/audiobooks_list.go`, modify `internal/api/router.go`.
### 1.1 Test
Add `internal/api/handlers/audiobooks_list_test.go` with a test that:
- Inserts two `media_items` rows (one `type='audiobook'`, one `type='movie'`)
- Calls the handler with a fake request
- Asserts only the audiobook is returned
If no test harness exists for handler-level tests in `internal/api/handlers/`, search neighbors (`grep -lE 'func.*Handler.*Test' internal/api/handlers/*_test.go`) for the established pattern. If integration testing requires a real DB and no harness exists, write the handler logic + a simple compile-time test only; defer the integration test (DONE_WITH_CONCERNS).
### 1.2 Handler
```go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type AudiobookHandler struct {
Items *catalog.ItemRepository
}
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
filter := apimw.AccessFilterFor(r)
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
return
}
resp := struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}{
Items: itemSummariesAsMaps(items),
Total: total,
Limit: limit,
Offset: offset,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func itemSummariesAsMaps(items []*models.MediaItem) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{
"content_id": it.ContentID,
"title": it.Title,
"year": it.Year,
"poster_url": it.PosterPath, // silo's poster column; UI prefixes with media-token base URL
})
}
return out
}
```
Verify `catalog.ItemRepository.Search` matches the signature above by reading `internal/catalog/item_repo.go:739`. Adjust if needed.
### 1.3 Route + commit
In `internal/api/router.go`, find where other v1 routes are mounted and add:
```go
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
```
Wire the handler construction near other handler constructors. The handler needs `catalog.ItemRepository` — already available in the router's deps.
Commit: `feat(audiobooks): list endpoint at GET /api/v1/audiobooks`.
---
## Task 2: Backend — audiobook detail
GET /api/v1/audiobooks/{id} → media_item + media_files (with chapters) + author/narrator from item_people + per-profile listening progress.
### 2.1 Handler
Create `internal/api/handlers/audiobooks_detail.go`. The handler queries:
1. `media_items WHERE content_id = $1 AND type = 'audiobook'`
2. `media_files WHERE content_id = $1` (returns paths + chapters JSONB)
3. `item_people JOIN people ON ... WHERE content_id = $1 AND kind IN (7, 8)`
4. `user_watch_progress WHERE content_id = $1 AND profile_id = $current`
Reuse existing repo methods where they exist. If not, add inline SQL.
Response shape:
```json
{
"audiobook": { "content_id": "...", "title": "...", "year": 2024, "overview": "...", "poster_url": "..." },
"author": "Test Author",
"narrator": "Test Narrator",
"files": [
{ "id": 123, "path": "/...", "duration_seconds": 3600, "chapters": [ {"index": 0, "title": "Intro", "start_seconds": 0, "end_seconds": 27.9} ] }
],
"progress": { "position_seconds": 1842.5, "updated_at": "2026-05-24T..." }
}
```
### 2.2 Route + commit
In `router.go`: `r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)`.
Commit: `feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}`.
---
## Task 3: Backend — progress endpoint
POST /api/v1/audiobooks/{id}/progress with body `{"position_seconds": 1842.5, "media_file_id": 123}`. UPSERTs `user_watch_progress`.
### 3.1 Handler
Create `internal/api/handlers/audiobooks_progress.go`. Body is JSON; handler parses, validates `position_seconds >= 0`, then upserts into `user_watch_progress` keyed on `(user_id, profile_id, media_item_id)`.
Reuse silo's existing progress-write helper if one exists (grep `INSERT INTO user_watch_progress` or `UpsertWatchProgress`). If not, write inline SQL using the existing DB pool from handler deps.
### 3.2 Route + commit
`r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)`.
Commit: `feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress`.
---
## Task 4: Frontend — TanStack Query hooks + types
**Files:**
- Create: `web/src/lib/audiobooks/types.ts`
- Create: `web/src/hooks/audiobooks/useAudiobookLibrary.ts`
- Create: `web/src/hooks/audiobooks/useAudiobook.ts`
- Create: `web/src/hooks/audiobooks/useReportAudiobookProgress.ts`
### 4.1 Types
```ts
// web/src/lib/audiobooks/types.ts
export interface AudiobookSummary {
content_id: string;
title: string;
year: number;
poster_url: string | null;
}
export interface AudiobookListResponse {
items: AudiobookSummary[];
total: number;
limit: number;
offset: number;
}
export interface AudiobookChapter {
index: number;
title: string;
start_seconds: number;
end_seconds: number;
}
export interface AudiobookFile {
id: number;
path: string;
duration_seconds: number;
chapters: AudiobookChapter[];
}
export interface AudiobookProgress {
position_seconds: number;
updated_at: string;
}
export interface AudiobookDetail {
audiobook: { content_id: string; title: string; year: number; overview: string; poster_url: string | null };
author: string;
narrator: string;
files: AudiobookFile[];
progress: AudiobookProgress | null;
}
```
### 4.2 Hooks
Use TanStack Query patterns silo already uses (grep `useQuery` for examples). Cache keys: `["audiobooks", "library", libraryId]` and `["audiobook", id]`. Progress mutation invalidates the detail query on success.
### 4.3 Commit
Commit: `feat(audiobooks): frontend types and TanStack Query hooks`.
---
## Task 5: Frontend — Audiobook Library page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookLibrary.tsx`
Grid of audiobook cards (poster + title + author). Use silo's existing card / grid components — grep `web/src/components/` for `MediaCard` or similar. Infinite scroll via `useInfiniteQuery` would be ideal but a simple paginated load-more is fine for MVP.
Route URL: `/audiobooks` (or `/audiobooks/library/:id` if there are multiple libraries). For MVP, single library route `/audiobooks` is fine.
Commit: `feat(audiobooks): library grid page`.
---
## Task 6: Frontend — Audiobook Detail page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookDetail.tsx`
Layout: cover image (poster) + metadata (title, author, year, overview) + chapter list + play button. Chapter list is collapsible; clicking a chapter starts playback at that chapter's start_seconds.
Route URL: `/audiobooks/book/:id`.
Commit: `feat(audiobooks): detail page with chapter list`.
---
## Task 7: Frontend — Audiobook Player
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookPlayer.tsx` (or `web/src/player/AudiobookPlayer.tsx`)
HTML5 `<audio>` element. The stream URL for an audiobook file is silo's existing `/api/v1/stream/{session_id}` — call POST /api/v1/playback/session to start a session (existing silo endpoint), then point the `<audio src>` at the stream URL. Position updates throttled to every 5-10 seconds + on pause/seek.
Controls: play/pause, skip-30s-forward, skip-30s-back, playback rate select (0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×, 3×), chapter list panel.
If silo's existing stream-session-start endpoint shape isn't obvious, grep for `playback/session` and copy the pattern from the video player.
Commit: `feat(audiobooks): HTML5 audio player with chapter navigation`.
---
## Task 8: Navigation + routing integration
**Files:**
- Modify: `web/src/App.tsx` (or wherever routes are registered) — add three new routes
- Modify: `web/src/components/Sidebar.tsx` (or equivalent) — add "Audiobooks" nav link
The sidebar link should only show when at least one audiobook library exists. Use a `useAudiobookLibrary` query with a short staleTime to gate visibility, OR a separate `useLibrariesOfKind("audiobooks")` hook.
Commit: `feat(audiobooks): wire navigation and routes`.
---
## Task 9: Build, lint, smoke
```bash
cd /opt/silo-server
go build ./...
go test ./internal/api/handlers/audiobooks_*_test.go ./internal/scanner/... ./internal/models/... ./internal/audiobooks/...
go vet ./...
cd web && pnpm run lint && pnpm run format:check && cd ..
make build
```
Rebuild silo image + force-recreate container:
```bash
sudo docker build -t silo:latest /opt/silo-server
sudo docker compose -p silo-prod up -d --force-recreate silo
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
curl -sS -o /dev/null -w "GET /api/v1/audiobooks -> %{http_code}\n" http://localhost:8090/api/v1/audiobooks
```
If any sweep changes (gofmt, prettier) were made, commit them with `chore(audiobooks): sweep formatting`.
---
## Risks
- Frontend tasks depend on silo's existing component shapes. Subagents will need to grep `web/src/components/` for the right patterns. If they invent ad-hoc components, ask them to use existing ones.
- The progress endpoint UPSERT shape depends on `user_watch_progress` PK shape — which discovery D4 confirmed is `(user_id, profile_id, content_id)`. Confirm and use exactly that ON CONFLICT target.
- Audiobook libraries may not yet exist on the test stack. To smoke-test, an operator must manually update a `media_folders.type` to `'audiobooks'` and trigger a rescan after the sub-plan 2 scanner branch lands.
@@ -0,0 +1,148 @@
# Audiobooks Absorption — Sub-plan 4: ABS-compat REST + Socket.io
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Port the audiobookshelf-compatible REST + Socket.io surface from `silo-plugin-audiobooks/internal/abs/` and `silo-plugin-audiobooks/internal/abssocket/` into `internal/audiobooks/abs/` and `internal/audiobooks/abssocket/`. **Preserve the plugin's file layout** but **rewrite every SQL query** against silo's existing tables (`media_items`, `media_files`, `episodes`, `user_watch_progress`, `user_playback_sessions`, `abs_sessions`, `people`, `item_people`). No new tables.
**Architecture:** Stage-based port. Support files first, then auth, then file serving, then progress, then author/series, then Socket.io, then router wiring. Out-of-scope features (bookmarks, smart collections, share links, playlists, podcasts/RSS in this sub-plan) are dropped — files containing them are not copied, or are copied with the unsupported handlers stubbed to 501 Not Implemented.
**Source plugin:** `/opt/silo_plugins/silo-plugin-audiobooks/internal/{abs,abssocket}/`
---
## Stage 1: Support files
**Files to copy and adapt:**
- `mount.go` — package mount + route registration shape
- `handler.go` — top-level handler type + dependency wiring
- `filter.go` — generic helpers
- `access_log.go` — request-log helper
- `minified.go` — generic helpers (likely small)
**Adapt steps:**
1. Replace import paths: `github.com/RXWatcher/silo-plugin-audiobooks/...` → `github.com/Silo-Server/silo-server/internal/audiobooks/...`
2. Replace plugin SDK references with direct silo equivalents:
- Plugin's `*pgx.Pool` field on Handler → silo's `*pgxpool.Pool` (same type via different import alias)
- Plugin's `Logger` → silo's `log/slog` package-level functions
- Plugin's config types → struct fields on `audiobooks.Service` (extended)
3. Remove any code that talks to the plugin SDK runtime (plugin proxy callbacks etc.)
4. Get `go build ./internal/audiobooks/abs/...` to compile cleanly even if no routes are wired yet.
Commit: `feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)`.
---
## Stage 2: Auth + JWT
**Files:**
- `jwt.go` — token generation + validation
- `login_internal_test.go` (keep as test) + `login_ratelimit.go`
- Whatever the actual `login.go` file is called (look for the login handler — likely inside `handler.go` or a dedicated file)
**Adapt:**
- The plugin's login handler posts credentials to silo's `/api/v1/auth/login` over HTTP. **In silo's in-process port, replace this HTTP round-trip with a direct call to silo's auth service** (find via `grep -nE 'auth.*Authenticate|auth.*Login' /opt/silo-server/internal/auth/*.go`). The plugin's network call is no longer needed because the code is now in-process.
- ABS sessions are stored in `abs_sessions` (migration 139 from sub-plan 1).
- JWT signing secret: store in `server_settings` under key `audiobooks.abs.jwt_secret` (auto-generated on first request if missing).
Commit: `feat(audiobooks): port ABS auth (JWT + login bridge)`.
---
## Stage 3: File serving + play session
**Files:**
- `file_handler.go` — handles ABS GET requests for audio file streams
- `play_response.go` — issues a play session to the client (URL + token)
**Adapt:**
- Plugin's `bookref` lookup table (linking ABS-style IDs to backend file IDs) → use silo's `media_files.id` directly. ABS-side item ID = silo's `content_id`; ABS-side file ID = silo's `media_files.id`.
- The stream URL ABS clients expect must point back at silo. Issue them a `/api/v1/direct-download?file_id={id}` URL signed with silo's existing media-token signer (grep `mediatoken` in `internal/`).
Commit: `feat(audiobooks): port ABS file handler + play session`.
---
## Stage 4: Progress tracking
**Files:**
- `progress_internal_test.go` (test, port)
- Whatever file owns the `/api/me/progress` ABS endpoint family — look in `handler.go` or grep ABS progress route paths
- `continue_listening.go`
**Adapt:**
- All progress reads/writes hit silo's `user_watch_progress` keyed on `(user_id, profile_id, content_id)`. Sub-plan 3's progress handler did this already — copy the patterns.
Commit: `feat(audiobooks): port ABS progress + continue listening`.
---
## Stage 5: Author / Series / Browse
**Files:**
- `author_series_handler.go`
- `collapse.go`
**Adapt:**
- Author rows come from silo's `people` + `item_people` (kind=7).
- Series grouping: if silo doesn't have an explicit "audiobook series" concept, use `media_items.title` prefix matching (e.g., books named "Foundation #1", "Foundation #2") for MVP. Document the heuristic.
Commit: `feat(audiobooks): port ABS author/series browse`.
---
## Stage 6: Socket.io
**Files:**
- `abssocket/server.go` + `abssocket/server_test.go`
**Adapt:**
- Reuse silo's `gorilla/websocket` upgrader. The plugin's standalone-listener escape hatch is no longer needed — mount under `/abs/socket.io/` on silo's main listener.
- Redis adapter behavior preserved if `REDIS_URL` is set (silo always has one in the docker setup).
Commit: `feat(audiobooks): port ABS Socket.io endpoint`.
---
## Stage 7: Router wiring
**Files:**
- Modify `internal/api/router.go` — mount `audiobookABSHandler` under `/abs/*` and the small set of `/api/*` legacy paths ABS clients hardcode
- Modify `internal/audiobooks/service.go` — add `ABSHandler` field, wire it from `audiobooks.New(...)`
- Modify `cmd/silo/main.go` — construct the ABS handler and pass it into the audiobooks service
**Routes to expose** (the minimum ABS-required set):
- `POST /abs/login` (login flow)
- `GET /abs/api/me` (profile)
- `GET /abs/api/libraries` (library list)
- `GET /abs/api/libraries/{id}/items` (browse)
- `GET /abs/api/items/{id}` (item detail)
- `GET /abs/api/items/{id}/file/{ino}` (stream)
- `POST /abs/api/me/progress/{libraryItemId}` (progress)
- `GET /abs/socket.io/` (Socket.io)
For ABS-legacy hard-coded paths that don't fit under `/abs/`: register them explicitly with the ABS auth middleware so they don't collide with silo's existing `/api/v1/*`.
Build + smoke:
```bash
sudo docker build -t silo:latest /opt/silo-server
sudo docker compose -p silo-prod up -d --force-recreate silo
curl -sS -o /dev/null -w "POST /abs/login -> %{http_code}\n" -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8090/abs/login
```
Expected: 400 (bad request body) or 401 (validation failed), NOT 404.
Commit: `feat(audiobooks): wire ABS routes into main router`.
---
## Stage 8: Build + smoke + lint sweep
Single final task. Same shape as sub-plan 2 Task 10.
---
## Risks
- The plugin's code expects a specific database schema. Some plugin queries may have no clean silo-table equivalent (e.g., the plugin's `bookref` mapping table). In that case, stub the handler to return 501 + log + flag in PR notes — don't add new tables.
- ABS clients have undocumented expectations (CORS headers, specific error shapes, etc.). Smoke testing against a real ABS client is the only way to find these. Defer to sub-plan 6's manual verification.
- Each stage commits independently and produces partial functionality (e.g., after stage 2 you can log in but can't list libraries). That's intentional — clients won't connect end-to-end until stage 7.
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
# Audiobook-native catalog filter fields
**Status:** Draft — not yet approved or executed.
**Context.** The audiobooks library page currently inherits the catalog's
movie/TV filter set. We've already (a) wired `libraryType="audiobook[s]"`
into the sort-relevance scope so video-only sorts (resolution, IMDb/RT
ratings, content rating) drop out, and (b) gated the obviously-irrelevant
video-only filter sections (Director, Writer, Producer, Studio, Network,
Video Quality) in `CollectionGuidedRulesEditor`. What remains is
**affirmative** support for audiobook-native filter dimensions: **author**,
**narrator**, **series**.
Commands assume the repository root is the cwd.
## Goal
Surface three new filter dimensions on the audiobook library page:
1. **Author** — `item_people.kind = PersonKindAuthor` (7).
2. **Narrator** — `item_people.kind = PersonKindNarrator` (8).
3. **Series** — `audiobook_series.series_name` (free-text or distinct list).
The user should be able to:
- See per-dimension counts/options in the filter sheet (populated from the
current scope, like Genres/Studios already do).
- Add a rule via the guided editor that filters items by exact author /
narrator / series.
## Non-goals
- Series **ordering** (we already have `audiobook_series.series_index`).
Sort by series is a separate piece of work; this plan focuses on
filtering only.
- Author/narrator typeahead UI improvements beyond the existing
`PersonSearchSelect` (reuse it with kind-scoped lookups).
## Backend
### `internal/catalog/catalog_resolver.go`
`CatalogFiltersResult` currently has `Genres`, `Studios`, `Networks`,
`Countries`, `OriginalLanguages`, `ContentRatings`, `Resolutions`,
`AudioLanguages`, `SubtitleLanguages`. Add:
```go
Authors []string `json:"authors,omitempty"`
Narrators []string `json:"narrators,omitempty"`
Series []string `json:"series,omitempty"`
```
In `listFiltersForSource`, add three parallel facet queries:
- `listDistinctPeopleByKind(ctx, scope, models.PersonKindAuthor)` — joins
`item_people` + `people` on the result set defined by the current
request scope (library_ids, media_scope=audiobook, etc.) and returns
distinct `people.name`.
- Same for `PersonKindNarrator`.
- `listDistinctAudiobookSeriesNames(ctx, scope)` — distinct
`audiobook_series.series_name` for the scope, joined via
`audiobook_series.content_id = media_items.content_id`.
All three need to respect the access filter the caller passes in (mirror
how Genres/Studios already gate by access).
### `internal/api/handlers/catalog.go`
Extend `catalogFiltersResponse` and `HandleGetCatalogFilters` to emit the
new fields. Mirror the pattern used for the existing ones — no flag
gating, audiobook filters should always be included when present.
### `internal/catalog/query_builder.go`
Three new field names: `author`, `narrator`, `series`. The first two
reuse `buildPersonClause` with the appropriate `PersonKind`. `series`
needs a small new clause:
```go
case "series":
// EXISTS (SELECT 1 FROM audiobook_series s
// WHERE s.content_id = media_items.content_id
// AND lower(s.series_name) = lower($N))
```
Register the three field names in `catalogQueryRuleFields` so the parser
accepts them. None of them are personalized — they don't go into
`catalogPersonalRuleFields`.
### Tests
- `internal/catalog/catalog_resolver_test.go` — extend with audiobook
scope: assert authors/narrators/series come back, scoped to libraries
the test user has access to.
- `internal/catalog/query_builder_test.go` — add cases for `author`,
`narrator`, `series` rules; verify the emitted SQL clauses match the
expected shape.
## Frontend
### `web/src/api/types.ts`
Extend `CatalogFiltersResponse` (and any `ItemFiltersResponse` base, if
shared) with optional `authors?: string[]`, `narrators?: string[]`,
`series?: string[]`.
Extend `QueryRule['field']` to include `'author' | 'narrator' | 'series'`
if it's a literal union (or no-op if it's `string`).
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
Add to `GuidedFormState`:
- `author: string`
- `narrator: string`
- `series: string`
Update `queryDefinitionToGuidedState` / `guidedStateToQueryDefinition` to
round-trip the three rules (mirror how `actor` / `director` are wired).
In the audiobook-only render path (already gated via `isAudiobookLibrary`),
add a new section *above* the country row:
```tsx
<div className="grid gap-4 md:grid-cols-2">
<Author picker /> {/* PersonSearchSelect with kind="author" */}
<Narrator picker /> {/* PersonSearchSelect with kind="narrator" */}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Series picker /> {/* SearchableSelect over filters.series */}
</div>
```
`PersonSearchSelect` likely needs a new `kind` prop so it can scope its
backend lookup to authors or narrators. If today it queries all kinds,
we'll add a query param + handler-side filter.
### `ActiveFilterBadges` + `catalogFilterBadges`
Add badge rendering for the three new fields so they appear in the
selected-filters chip row above the editor.
### Tests
- `web/src/components/collections/CollectionGuidedRulesEditor.test.tsx`
— render with `libraryType="audiobooks"` and a filters payload
containing authors/narrators/series; assert all three sections appear
and update the query definition correctly.
## Migration / data
No schema changes required. `PersonKindAuthor=7` and `PersonKindNarrator=8`
already exist in `internal/models/media.go`. `audiobook_series` already
holds series names (migration 145). The scanner is already writing both.
## Risk
- **Filter facet performance.** Three new distinct-value queries per
`/catalog/filters` request. The existing facet queries use the same
`facetFetcher` infrastructure, so they'll inherit the same query-time
budget. For an audiobook library on the order of a few thousand items
this should be fine; if it's not, we can cache author/narrator lookups
more aggressively (they change rarely).
- **PersonSearchSelect kind scoping.** If the component today emits a
single combined kind=any search, we need to add kind-filtered variants
without breaking the existing actor/director/writer/producer pickers.
- **Series name normalization.** Backfill at migration 145 used regex
parsing; some items may have inconsistent casing or whitespace.
Distinct facet query should `TRIM(series_name)` and present `LOWER`
for matching; otherwise duplicates show up in the picker.
## Rollout
This is gated entirely behind `libraryType === "audiobook[s]"` on the
frontend, and the new filter fields are additive on the backend. No
feature flag needed. Ship in two PRs if reviewer prefers:
1. **Backend** — filter response fields + query-builder clauses + tests.
2. **Frontend** — types + GuidedFormState + editor sections + tests.
Or a single PR if the diff stays reviewable (~600 lines including tests).
## Verification
- Add new tests as above; `make lint` and `cd web && pnpm vitest run`
should pass.
- Manually: open the audiobook library filter sheet, pick an author and
a narrator, confirm the result set narrows. Pick a series, confirm
same.
@@ -0,0 +1,123 @@
# `audiobook_series` data cleanup
**Status:** Draft. Findings + options; no implementation yet.
## Problem
Migration `145_audiobook_series.up.sql` (which created the
`audiobook_series` table) shipped with a regex backfill that
over-matched. On this server's 220k-book audiobook library it produced
**162,322 distinct series names**, of which **141,591 (87%) are
singletons** — books that are the only "member" of their named series.
Spot checks make the failure mode obvious: rows like
- `series_name = "Computer Programming: This Book Includes: SQL, …"` for a standalone book whose subtitle happened to contain `"#2022 Version"`
- `series_name = "Reparación de crédito [Credit Repair]"` (the title verbatim)
- `series_name = "Cómo Atraer a las Mujeres … [How to Attract Women …]"` (the title verbatim)
The regex (`'^.+[^\s-]\s+\d+(?:\.\d+)?\s*-\s*.+$'`) treats any numeric
chapter / edition / part token followed by ` - ` as series-N-of-book
syntax. The real series (Star Wars, Warhammer 40k, Animorphs, Redwall,
*In Death*, etc.) live in the 733-row "large series" bucket; the
9,075 pairs and 10,923 small clusters mix real and false-positive.
The cap added in `b95494c` keeps the user-facing dropdown manageable
(top 1000 alphabetically), but the underlying data is still polluted —
sorting by series, the Series filter dropdown, and any future
"recommended" surfacing of series will all surface noise until the
backfill is repaired.
Commands assume the repository root is the cwd.
## Options
### A — delete singletons (destructive)
```sql
DELETE FROM audiobook_series
WHERE series_name IN (
SELECT series_name FROM audiobook_series GROUP BY series_name HAVING COUNT(*) = 1
);
```
- **Pros:** simple, removes the bulk of the noise in one shot, no
schema change.
- **Cons:** also deletes legitimate "first book of a not-yet-complete
series" rows. Reversal requires a re-scan.
### B — filter singletons at query time, leave data alone
In `listDistinctAudiobookSeriesWithSource`, group by series name and
`HAVING COUNT(*) >= 2`.
- **Pros:** non-destructive; the table keeps full information for
detail-page lookups and series-detail surfaces.
- **Cons:** hides legitimate one-book-only series from the filter
dropdown. Series-detail and sort behaviour unchanged (still surface
singletons). The 11.7 MB payload was already capped to 1000 in
`b95494c`, so the size win from this option is small.
### C — mark backfill provenance, delete only backfilled singletons
Add a `source` column to `audiobook_series` (`'scanner' | 'backfill'`)
in a new migration. Migration 145's backfill rows get `source =
'backfill'`; scanner-written rows get `'scanner'`. Then delete only the
backfilled singletons.
- **Pros:** distinguishes legitimate from spurious without guessing.
- **Cons:** retroactive — we'd have to assume all current rows are
backfill (which they mostly aren't anymore — the scanner has been
writing too). Without a marker added at backfill time, can't tell
what's what.
### D — wipe + re-scan (clean slate)
```sql
TRUNCATE audiobook_series;
```
Then trigger a full library re-scan so the scanner writes only the
real series_name values it extracts from tags.
- **Pros:** the cleanest end state; the scanner is authoritative.
- **Cons:** long re-scan window (the user already noticed 219k books
is slow to scan); any books not currently scannable lose their
series info entirely. Destructive.
## Recommendation
**Option B as a quick win** — non-destructive, ships as a one-line SQL
change in the facet helper, makes the Series filter dropdown useful
immediately. The dropdown stops showing 87% noise.
**Option D as the eventual fix** — once the scanner has been verified
to write series_name authoritatively (including the series_index from
real tags), `TRUNCATE audiobook_series` + re-scan is the right end
state. Need to confirm scanner behaviour before pulling the trigger.
Option A is too blunt (drops real first-books-of-series). Option C is
over-engineered without a marker added at backfill time.
## What this plan does not cover
- Whether the scanner currently writes `audiobook_series` rows
authoritatively for every audiobook it touches, or only when tags
contain explicit series metadata. Need to check
`internal/scanner/audiobook.go` (or wherever the audiobook_series
upsert lives) before proposing option D as the long-term path.
- Whether *Search Series* (the user-facing series-detail page, if
any) would also benefit from the same cleanup.
- Sort-by-series on the library page — even with option B in place,
the sort still groups by every series_name including singletons,
so books in fake "series" appear under their fake series name.
Acceptable for now; revisit alongside option D.
## Verification (once an option is picked)
- Re-run the data sanity query in this doc's intro and confirm the
singleton count dropped where expected.
- Open the Series filter dropdown on the audiobook library page and
confirm only multi-book series show up.
- `go test ./internal/catalog/...` + `cd web && pnpm vitest run` still
pass.
@@ -0,0 +1,143 @@
# Server-side typeahead for catalog facets
**Status:** Draft. Architecture sketch; no implementation yet.
## Why
`b95494c` capped `/api/v1/catalog/filters` facet responses to 1000
distinct values each, which fixed an 11.7 MB payload on the audiobook
library (88k authors, 92k narrators, 161k series). The cap is a
stopgap — a user looking for an author past the first 1000
alphabetically can't find them through the dropdown.
Parallel context: a separate investigation (transcript in the
2026-05-27 sessions) compared how `librarymanagerre`, `booklore-ng`,
and `audiobookshelf` paginate large libraries. The cursor +
virtual-scroll camp (librarymanagerre, booklore-ng's
`@tanstack/react-virtual`) scales to 250k+ items by never sending the
client a full list; the eager-shelves camp (audiobookshelf web +
mobile) renders one DOM div per item and falls over above ~30–50k.
The 11.7 MB facet issue here is the same shape on a different surface
— "too much data shipped to the client at once."
The cleanest fix is **server-side typeahead**: the client sends a
search prefix as the user types, the server returns the top N matches
for that prefix.
Commands assume the repository root is the cwd.
## Surface
### `/api/v1/catalog/filters/search`
New endpoint, scoped per facet, query parameters mirror
`/api/v1/catalog/filters` (`source`, `library_id`, etc.) plus:
- `facet=author|narrator|series|studio|network|country|genre|original_language|content_rating`
- `q=<prefix>` — the user's typed prefix (1-64 chars, trimmed)
- `limit=<N>` — capped at e.g. 50
Response:
```json
{ "matches": ["Brandon Sanderson", "Brandon Sanderson & Steven Erikson", ...], "has_more": false }
```
Returns up to `limit` matches sorted by:
1. exact-prefix match first (`q` matches the start of the value)
2. then case-insensitive substring match
3. then alphabetical
`has_more` is true when the underlying result set was truncated.
### `/api/v1/catalog/filters` keeps the current shape
No change to the existing endpoint — it still returns the capped top
1000 per facet for the initial dropdown render. The typeahead surface
takes over once the user starts typing.
## Backend changes
### `internal/catalog/catalog_resolver.go`
New method `SearchFacetWithOptions(ctx, req, access, facet, prefix,
limit)` that:
1. Resolves the same access/scope as `ListFiltersWithOptions`.
2. Dispatches to a facet-specific helper based on `facet`.
3. Returns the matches + has_more flag.
Add to `facetFetcher` interface:
```go
SearchPeopleByKind(ctx, kind models.PersonKind, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchAudiobookSeries(ctx, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchDistinctArrayColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchDistinctScalarColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
```
Each helper runs the existing facet SQL with an added
`WHERE LOWER(<value>) LIKE LOWER($N || '%')` (prefix match) and
`LIMIT N+1` (so we can detect has_more by checking if the result set
exceeds N).
### `internal/api/handlers/catalog.go`
New handler `HandleCatalogFacetSearch` mounted at
`/api/v1/catalog/filters/search`.
## Frontend changes
### `web/src/components/ui/searchable-select.tsx` (or a new variant)
`SearchableSelect` today does client-side filtering over the full
`options` array. Replace it for the high-cardinality facets with a
debounced server search:
- Type `<= 100ms` of inactivity → fire `/catalog/filters/search?facet=author&q=...`
- Reset focus + cancel in-flight when the user keeps typing
- Show the first 50 matches; "show more" disabled (the user types more
to narrow further)
The existing `SearchableSelect` stays for low-cardinality facets
(genres, content_ratings, etc.) where the initial 1000 is plenty.
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
Author / Narrator / Series sections switch to the new typeahead-backed
select. Studio / Network / Country can stay on `SearchableSelect`
(low-cardinality) or migrate later for consistency.
## Tests
- Backend: search SQL emits the LIKE prefix + LIMIT N+1, has_more
semantics, access filter still gates the result set.
- Frontend: typeahead component debounces, cancels stale requests,
renders empty / loading / no-results states.
## Migration
None — additive endpoint. Frontend can be rolled out incrementally:
audiobook-only sections first (since they're the worst offenders),
others later.
## What this plan does not cover
- The audiobookshelf-app pagination question from the separate
transcript. That's a different surface (the ABS-compat
`/abs/api/libraries/{id}/items` endpoint on `:13378`), not the silo
native catalog. Same architectural family but the patch lives in
`internal/audiobooks/abs/libraries_handler.go`, not catalog.
- A general client-side virtual scroll for the library grid itself.
That's the cousin problem ("250k books rendered as 125k shelf
divs"); cross-applies but is its own work.
## Risk / rollout
- New endpoint is opt-in for the frontend — existing callers keep
using the bulk `/filters` response.
- Typeahead latency budget: each keystroke fires one DB query against
the same indexes the bulk facets already use. Should be fast
(`item_people(content_id, kind)` covers people lookups,
`audiobook_series(content_id)` covers series, and a `LOWER()`
functional index already exists on
`audiobook_series_name_lower`). Verify with EXPLAIN before shipping.
@@ -0,0 +1,314 @@
# Collections Unification — Sub-project 1: Schema Migration
> **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:** Land migration 156 — extend `user_personal_collections` to admit `'playlist'` and `'smart'` types, add nullable `sub_item_id` to its items table, add `media_types` filter to `page_sections`, move the single existing `abs_playlists` row into the canonical store, and drop the five `abs_*` collection tables.
**Architecture:** Pure schema migration + one-row data move. No application code changes. The follow-up sub-projects 2–4 wire silo to the new schema; this one is just the storage foundation. Hard cutover — the `abs_*` tables are near-empty (1 playlist row, 0 of everything else) so rollback risk is minimal.
**Tech Stack:** PostgreSQL 18, raw SQL migrations under `migrations/`, no Go code in this sub-project.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.1, §4.4, §5.
---
## File map
**Create:**
- `migrations/156_unify_user_collections.up.sql`
- `migrations/156_unify_user_collections.down.sql`
**No Go code modified in this sub-project.** Application code keeps querying the (now-dropped) `abs_*` tables until sub-projects 3 and 4 land, which means **silo will break in dev/staging until sub-project 3 ships.** Land this sub-project and sub-project 3 together (single MR) if a production deploy is imminent. Otherwise it's safe to ship in isolation on a feature branch that hasn't been merged yet.
---
## Task 1: Up-migration body
**Files:**
- Create: `migrations/156_unify_user_collections.up.sql`
- [ ] **Step 1: Write the migration file**
```sql
-- Unify user-owned lists into user_personal_collections.
--
-- See docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md
-- for design rationale.
-- 1. Sub-item granularity column on the canonical items table.
-- Empty string for whole-item entries; populated for podcast-episode
-- playlist entries (sub_item_id == abs_playlist_items.episode_id).
ALTER TABLE user_personal_collection_items
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
-- 2. Widen the collection_type domain. The pre-existing CHECK (if any)
-- only admits 'manual' and 'synced'.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
-- 3. Move the existing abs_playlists row(s) into the canonical store.
-- is_public maps to is_shared; profile_id (uuid) is stringified.
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
SELECT
id,
user_id,
COALESCE(profile_id::text, ''),
name,
description,
'playlist',
is_public,
created_at,
updated_at,
COALESCE(profile_id::text, '')
FROM abs_playlists;
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT
p.user_id,
i.playlist_id,
i.library_item_id,
i.episode_id,
i.position,
i.added_at
FROM abs_playlist_items i
JOIN abs_playlists p ON p.id = i.playlist_id;
-- 4. Drop the abs_* collection tables. abs_playlist_items has a FK to
-- abs_playlists, so the order matters.
DROP TABLE abs_playlist_items;
DROP TABLE abs_playlists;
DROP TABLE abs_collection_items;
DROP TABLE abs_user_collections;
DROP TABLE abs_smart_collections;
-- 5. Media-type filter on page_sections. Default preserves current
-- behavior (existing rails surface movies+series only).
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
- [ ] **Step 2: Verify the file compiles as valid SQL**
The repo embeds migrations via `migrations/embed.go`. The migration runner will syntax-check on load.
Run: `go build ./...`
Expected: clean (the embed package compiles fine even if a new file is added).
- [ ] **Step 3: Commit (up only — down comes next task)**
Don't commit yet. Commit happens after Task 2's down-migration so the up/down pair lands atomically.
---
## Task 2: Down-migration body
**Files:**
- Create: `migrations/156_unify_user_collections.down.sql`
The down migration is intentionally lossy in reverse: rolling back loses any `'playlist'`/`'smart'` collections that were created after the up. With the up bringing in 1 row, lossy reverse is acceptable.
- [ ] **Step 1: Find the abs_* CREATE TABLE statements to inline**
Read these files and copy their `CREATE TABLE` bodies (and `CREATE INDEX` statements; **omit** any `DROP TABLE IF EXISTS` boilerplate at the top — the down migration creates from clean state):
```bash
cat migrations/149_abs_user_collections.up.sql
cat migrations/150_abs_collection_items.up.sql
cat migrations/151_abs_playlists.up.sql
cat migrations/152_abs_playlist_items.up.sql
cat migrations/153_abs_smart_collections.up.sql
```
- [ ] **Step 2: Write the down migration**
```sql
-- Reverse migration 156. Lossy in reverse — playlist/smart rows
-- created after the up migration are deleted, not migrated back.
-- 1. Remove the page_sections column.
ALTER TABLE page_sections DROP COLUMN media_types;
-- 2. Recreate the abs_* tables empty. Schemas are inlined from the
-- original up migrations 149–153 — keep identical (column types,
-- constraint names, index names) so any tool keyed off those names
-- sees the same shape.
--
-- BEGIN inlined from migrations/149_abs_user_collections.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- BEGIN inlined from migrations/150_abs_collection_items.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- BEGIN inlined from migrations/151_abs_playlists.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
-- END inlined
-- BEGIN inlined from migrations/152_abs_playlist_items.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
-- END inlined
-- BEGIN inlined from migrations/153_abs_smart_collections.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- 3. Remove rows we promoted from abs_playlists during the up.
DELETE FROM user_personal_collection_items
WHERE collection_id IN (
SELECT id FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart')
);
DELETE FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart');
-- 4. Restore the narrow CHECK constraint.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced'));
-- 5. Drop the sub_item_id column.
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
```
Replace each `<PASTE EXACT ... statements>` block with the actual SQL from the corresponding `migrations/14X_abs_*.up.sql` file. Do NOT skip any constraint or index — the round-trip test below verifies parity.
- [ ] **Step 3: Verify SQL syntactic validity**
Run: `go build ./...`
Expected: clean.
- [ ] **Step 4: Commit the up+down pair**
```bash
git add migrations/156_unify_user_collections.up.sql migrations/156_unify_user_collections.down.sql
git commit -m "feat(migrations): 156 unify user collections
Adds sub_item_id to user_personal_collection_items, widens
collection_type to include 'playlist' and 'smart', adds media_types
filter to page_sections, moves the existing abs_playlists row into
the canonical store, and drops the abs_* collection tables.
Lays the storage foundation for sub-projects 2-4 to wire the
application against."
```
---
## Task 3: Migration round-trip test
**Files:**
- Create: `migrations/156_unify_user_collections_test.go` (only if a similar pattern exists in the repo)
The repo doesn't have a standard "migration round-trip test" harness today (verify with `find . -name "migration*_test.go" -o -name "*round_trip*_test.go" | head`). If no harness exists, this task is **best-effort manual verification**:
- [ ] **Step 1: Verify a harness pattern exists, or skip to manual verification**
Run:
```bash
find . -name "migration*_test.go" -not -path "*/node_modules/*" 2>/dev/null | head
grep -rln "migrate.Up\|migrate.Down\|migrate.Steps" --include="*_test.go" 2>/dev/null | head
```
If no harness emerges, fall through to Step 2 (manual). Otherwise add a Go test following the existing harness pattern that:
1. Migrates to 155.
2. Inserts a fake `abs_playlists` row (id `test-pl-1`, user 1, name `test`).
3. Inserts a fake `abs_playlist_items` row referencing it.
4. Migrates up to 156.
5. Asserts `user_personal_collections WHERE collection_type='playlist' AND id='test-pl-1'` exists and `user_personal_collection_items WHERE collection_id='test-pl-1'` exists.
6. Migrates down to 155.
7. Asserts the `abs_playlists` table exists and is empty (the row is GONE — down is lossy by design).
- [ ] **Step 2: Manual verification against a throwaway DB**
If no harness, do the following manually once and capture the output in the MR description:
```bash
# 1. Spin up an empty postgres
docker run --rm -d --name pgcheck -e POSTGRES_PASSWORD=x -p 5443:5432 pgvector/pgvector:pg18
sleep 5
# 2. Apply migrations up to 155 (use the existing migrate binary or schema dump)
# Adapt this to whatever the repo uses to run migrations against an arbitrary DB.
# 3. Insert a fake abs_playlists + items row.
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
INSERT INTO abs_playlists(id, user_id, name) VALUES ('test-pl-1', 1, 'test');
INSERT INTO abs_playlist_items(playlist_id, library_item_id, position) VALUES ('test-pl-1', 'foo', 0);
"
# 4. Apply migration 156.
# 5. Verify the row landed in user_personal_collections.
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
SELECT collection_type, name FROM user_personal_collections WHERE id='test-pl-1';
SELECT collection_id, media_item_id, sub_item_id FROM user_personal_collection_items WHERE collection_id='test-pl-1';
"
# Expected: collection_type='playlist', name='test'; one item row with sub_item_id=''
# 6. Apply down migration.
# 7. Verify abs_playlists exists (empty) and user_personal_collections has no playlist rows.
# Cleanup
docker rm -f pgcheck
```
- [ ] **Step 3: If you added a Go test, commit it**
```bash
git add migrations/156_unify_user_collections_test.go
git commit -m "test(migrations): 156 round-trip verifies abs_playlists migrate to canonical"
```
If only manual verification was done, capture the output in the MR description instead — no commit needed.
---
## Verification (after merge)
1. **Dev DB** — run `make dev-backend` against a fresh DB. Migration 156 applies cleanly. `\d user_personal_collection_items` shows the `sub_item_id` column. `\d page_sections` shows `media_types`.
2. **Existing data migration** — on a copy of the production DB (or staging if available), run migration 156 and verify the single existing `abs_playlists` row is now in `user_personal_collections` with `collection_type='playlist'`:
```sql
SELECT id, name, collection_type FROM user_personal_collections WHERE collection_type='playlist';
```
3. **abs_* tables gone** — confirm:
```sql
SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'abs_%collection%';
-- Should return 0 rows.
```
`abs_sessions`, `abs_bookmarks`, `abs_playback_sessions`, `abs_rss_feeds` remain — those aren't in scope here.
4. **Silo binary builds against the new schema** — `go build ./...` is green even though the ABS store adapters still reference the dropped tables. They reference them via SQL strings, so compile-time is fine; runtime will fail until sub-project 3 rewrites them. **Do NOT** deploy without sub-project 3, or stash this migration behind a feature flag.
---
## Self-Review
**Spec coverage:**
- `sub_item_id` column ✓ (Task 1)
- `collection_type` widened to playlist/smart ✓ (Task 1)
- Move existing abs_playlists row ✓ (Task 1)
- Drop abs_* tables ✓ (Task 1)
- `page_sections.media_types` ✓ (Task 1)
- Down migration ✓ (Task 2)
- Round-trip test ✓ (Task 3, best-effort)
**Placeholder scan:** The `<PASTE EXACT ... statements>` blocks in Task 2 Step 2 are template placeholders that the implementer must fill in by copying from the existing migration files. Each is bounded by explicit BEGIN/END comment markers naming the source file. The instructions in Step 1 say to read those files first; Step 2's block then becomes literal SQL. Not a "TBD" in the bad sense — it's a deliberate copy-from-source step.
**Type consistency:** `sub_item_id` (text, NOT NULL DEFAULT '') consistent in up + down + verification queries. `collection_type` enum values consistent across the spec, up, down, and CHECK constraint.
**Risk:** All schema-level, no application changes. The biggest risk is forgetting to ship sub-project 3 in the same release — runtime will start erroring on ABS endpoints the moment migration 156 applies, because the adapters still query the dropped tables.
@@ -0,0 +1,274 @@
# Collections Unification — Sub-project 2: Smartcoll Engine Lift
> **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:** Move the smart-collection rule engine from `internal/audiobooks/smartcoll/` to `internal/smartcoll/` so all media types (movie, series, audiobook) share one evaluator. Pure code move + import update; no logic changes.
**Architecture:** The 4-file package (~1060 LOC) currently lives in `internal/audiobooks/smartcoll/`. We relocate it verbatim and rewire its imports. The existing in-place audiobook caller (`internal/audiobooks/abs_smart_collection_store.go`) updates its import path. Sub-projects 3 and 4 will later call the engine from new sites (ABS adapter rewrites, section recipes); those plans cover their own wiring.
**Tech Stack:** Go. Standard `gopls`-style refactor: move, fix imports, run tests.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.2, §4.6.
**Predecessor sub-project:** None — this lands independent of sub-project 1. Order doesn't matter.
---
## File map
**Move (4 files):**
- `internal/audiobooks/smartcoll/evaluator.go` → `internal/smartcoll/evaluator.go`
- `internal/audiobooks/smartcoll/evaluator_test.go` → `internal/smartcoll/evaluator_test.go`
- `internal/audiobooks/smartcoll/query.go` → `internal/smartcoll/query.go`
- `internal/audiobooks/smartcoll/query_test.go` → `internal/smartcoll/query_test.go`
**Modify (import path):**
- Every file that imports `github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll` — change to `github.com/Silo-Server/silo-server/internal/smartcoll`.
**Add (new test, cross-type coverage):**
- `internal/smartcoll/cross_type_test.go` — three tests verifying audiobook-specific rules no-op against non-audiobook items.
---
## Task 1: Move the package directory
**Files:** see "Move" list above.
- [ ] **Step 1: Pre-check current contents**
```bash
ls internal/audiobooks/smartcoll/
wc -l internal/audiobooks/smartcoll/*.go
```
Expected: 4 files (evaluator.go, evaluator_test.go, query.go, query_test.go). If the contents differ, pause and report — the plan assumes this exact set.
- [ ] **Step 2: Verify nothing outside the package references it (yet) besides the known caller**
```bash
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" .
```
Expected: `internal/audiobooks/abs_smart_collection_store.go` and possibly its test, plus the package's own test files. If you find any other importer, pause and add it to the import-update list in Task 2.
- [ ] **Step 3: Create the new directory and move the files**
```bash
mkdir -p internal/smartcoll
git mv internal/audiobooks/smartcoll/evaluator.go internal/smartcoll/evaluator.go
git mv internal/audiobooks/smartcoll/evaluator_test.go internal/smartcoll/evaluator_test.go
git mv internal/audiobooks/smartcoll/query.go internal/smartcoll/query.go
git mv internal/audiobooks/smartcoll/query_test.go internal/smartcoll/query_test.go
rmdir internal/audiobooks/smartcoll
```
`git mv` preserves blame history.
- [ ] **Step 4: Update the package declaration in each moved file**
The package name was `smartcoll`; it stays `smartcoll`. Open each moved file and **verify** the `package smartcoll` line is unchanged. If any file declares `package audiobooks_smartcoll` or similar, fix it to `package smartcoll`.
```bash
head -1 internal/smartcoll/*.go
```
Expected: every line reads `package smartcoll`. If any differ, fix with sed:
```bash
sed -i '1s/^package .*/package smartcoll/' internal/smartcoll/<filename>.go
```
- [ ] **Step 5: Build — this WILL fail until Task 2 runs**
```bash
go build ./...
```
Expected: compilation error in `internal/audiobooks/abs_smart_collection_store.go` because its import path now points at a non-existent directory. That's expected — Task 2 fixes it. **Do not commit yet.**
---
## Task 2: Update all imports
**Files:**
- Modify: every file from Task 1 Step 2's grep result.
- [ ] **Step 1: Update each importer**
The typical pattern in the existing caller is:
```go
import (
// ...
"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"
)
```
Becomes:
```go
import (
// ...
"github.com/Silo-Server/silo-server/internal/smartcoll"
)
```
For each file in the Task 1 Step 2 grep result, run:
```bash
sed -i 's|"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"|"github.com/Silo-Server/silo-server/internal/smartcoll"|' <file>
```
Or edit by hand using your editor's find-and-replace.
- [ ] **Step 2: Verify no stale import remains**
```bash
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" . || echo "clean"
```
Expected: `clean` (no matches).
- [ ] **Step 3: Build + test**
```bash
go build ./...
go test ./internal/smartcoll/ ./internal/audiobooks/... -short -timeout 60s
go vet ./internal/smartcoll/ ./internal/audiobooks/...
```
Expected: all green. The moved tests still pass because the engine logic is unchanged; the package path changed but the test code itself is package-local.
- [ ] **Step 4: Commit the move + import fix**
```bash
git add internal/smartcoll/ internal/audiobooks/
git commit -m "refactor(smartcoll): lift engine to internal/smartcoll
Moves the smart-collection rule engine out of internal/audiobooks/
so movie/TV smart collections can share the same evaluator.
No logic changes. Tests move with their package and continue to pass."
```
`git log --follow internal/smartcoll/evaluator.go` should show the prior history at the old path.
---
## Task 3: Cross-type smoke tests
**Files:**
- Create: `internal/smartcoll/cross_type_test.go`
The existing tests cover audiobook-specific rule evaluation. Sub-project 4 will start asking the engine to evaluate against movies + TV, so we add three small tests that nail down the no-op contract for audiobook-specific predicates against non-audiobook items.
- [ ] **Step 1: Find the audiobook-specific rule kinds**
```bash
grep -nE 'narrator|series_position|"type":"audiobook"' internal/smartcoll/query.go | head -10
```
Expected: rule-kind constants named like `RuleKindNarrator`, `RuleKindSeriesPosition`, or similar. Note the exact names — the test below references them. **If the rule kinds are not actually present (rule registration happens elsewhere), pause and report — the spec assumed they exist.**
- [ ] **Step 2: Read the existing query/evaluator test fixtures**
```bash
sed -n '/func Test/,/^func /p' internal/smartcoll/evaluator_test.go | head -60
```
Note the harness pattern used (how an evaluator is constructed in tests, how it's given items, how match-results are asserted). Mirror that pattern in the new tests.
- [ ] **Step 3: Write the failing tests**
Create `internal/smartcoll/cross_type_test.go`:
```go
package smartcoll
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
// TestNarratorRuleNoopOnMovie verifies that an audiobook-specific narrator
// rule treats movie items as a no-op (matches everything, since the rule
// can't apply). This is the cross-type contract that lets the engine
// evaluate against mixed-type item sets without errors.
func TestNarratorRuleNoopOnMovie(t *testing.T) {
// Adapt to the actual evaluator-construction API discovered in Step 2.
// Expected: build an evaluator with a narrator-name predicate, evaluate
// it against a movie item, assert no error AND the item is matched
// (or excluded — pick whichever the existing audiobook tests treat as
// "no-op" and document it).
t.Skip("Implement once the evaluator API is read from existing tests")
}
// TestSeriesPositionRuleNoopOnSeries verifies the audiobook series_position
// rule has no effect on a TV series item (which has its own series concept,
// distinct from book series).
func TestSeriesPositionRuleNoopOnSeries(t *testing.T) {
t.Skip("Implement once the evaluator API is read from existing tests")
}
// TestLibraryIdFilterAppliesToAllTypes verifies that the simple library_id
// predicate (the silo-native query_definition subset) evaluates correctly
// against any media type. This locks in compat with existing
// user_personal_collections rows after migration 156.
func TestLibraryIdFilterAppliesToAllTypes(t *testing.T) {
t.Skip("Implement once the evaluator API is read from existing tests")
}
var _ = models.MediaItem{} // import-stability marker; remove once tests are real
```
- [ ] **Step 4: Fill in the test bodies**
Replace each `t.Skip(...)` with a real test using the harness pattern from the existing tests. The exact code depends on the evaluator's public surface (constructor, evaluate method, predicate registration). **Read `internal/smartcoll/evaluator.go` and the existing test file once before writing the bodies** — don't guess at the API.
If the evaluator's no-op behavior for type-mismatched rules turns out to be "exclude the item" rather than "match the item", adjust the assertions to reflect that — but document the chosen semantic in a comment above each test so future readers know which it is.
- [ ] **Step 5: Run the new tests**
```bash
go test ./internal/smartcoll/ -run "Cross|Noop|LibraryIdFilter" -v
```
Expected: all three PASS, and the existing tests in the package continue to pass.
- [ ] **Step 6: Commit**
```bash
git add internal/smartcoll/cross_type_test.go
git commit -m "test(smartcoll): cross-type evaluator contract
Locks in the no-op semantics for audiobook-specific rules evaluated
against non-audiobook items, plus library_id filter compat across
all types."
```
---
## Verification (after merge)
1. `git log --follow internal/smartcoll/evaluator.go` shows the prior history at `internal/audiobooks/smartcoll/evaluator.go` — confirms blame preserved.
2. `go test ./internal/smartcoll/ -v` runs both the moved tests and the three new cross-type tests, all passing.
3. `grep -rln 'internal/audiobooks/smartcoll' --include="*.go" .` returns empty.
4. No production behavior change. Smart collections in the ABS API still resolve via the engine; the engine just lives at a new path.
---
## Self-Review
**Spec coverage:**
- Move package to `internal/smartcoll/` ✓ (Task 1)
- Update import paths ✓ (Task 2)
- Cross-type evaluator tests ✓ (Task 3)
**Placeholder scan:** Task 3's `t.Skip(...)` lines are explicit failing-test stubs that Step 4 fills in. The instructions in Step 4 say to read the existing tests first rather than guess. Not a TBD — it's a "read-existing, then write" workflow with explicit guidance.
**Type consistency:** Package name `smartcoll` consistent. Import path `github.com/Silo-Server/silo-server/internal/smartcoll` consistent. Rule-kind names depend on what Step 1 of Task 3 discovers — the plan flags this explicitly.
**Risk:** Pure refactor. The chance of breakage is low (Go's import system catches missed renames at compile time). The cross-type tests are new functionality, but they assert behavior that already exists in the engine (no-op on type-mismatch) so they should pass without code changes.
@@ -0,0 +1,515 @@
# Collections Unification — Sub-project 3: ABS Store Adapter Rewrites
> **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:** Rewrite the three ABS store adapters (`abs_collection_store.go`, `abs_playlist_store.go`, `abs_smart_collection_store.go`) to query the canonical `user_personal_collections` tables instead of the dropped `abs_*` tables. The wire shape returned by ABS HTTP endpoints must remain byte-identical so the ABS Android/iOS apps don't notice anything changed.
**Architecture:** The three adapter files expose a struct + ~6 methods each (List, Get, Create, Update, Delete, ListItems, AddItem, RemoveItem) consumed by `internal/audiobooks/abs/` HTTP handlers. We keep every public method signature unchanged and replace each SQL body with one that maps to `user_personal_collections` filtered by `collection_type`. Granular podcast-episode entries map to the new `sub_item_id` column. Library scoping (which library this list "belongs to" in ABS UI) is stored in `query_definition.library_ids`.
**Tech Stack:** Go, `pgx/v5`, `database/sql`-style query patterns from the existing adapters.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.3, §4.5.
**Predecessor sub-projects:**
- Sub-project 1 (migration 156) **must** land before this. The new tables/columns are required.
- Sub-project 2 (smartcoll lift) needed only for the smart-collection adapter — if 2 hasn't landed, Task 3 below can either wait for it or temporarily keep the old import path.
---
## File map
**Modify (rewrite bodies, keep method signatures):**
- `internal/audiobooks/abs_collection_store.go` (193 lines today; ~8 methods)
- `internal/audiobooks/abs_playlist_store.go` (210 lines today; ~9 methods including `coverArg`)
- `internal/audiobooks/abs_smart_collection_store.go` (~140 lines today; ~6 methods)
**Add (snapshot tests for wire-shape preservation):**
- `internal/audiobooks/abs_collection_store_test.go` (if not already present — check first)
- `internal/audiobooks/abs_playlist_store_test.go`
- `internal/audiobooks/abs_smart_collection_store_test.go`
**No new files** beyond those three test files. No new exported types.
---
## Task 1: Capture pre-cutover wire shapes (snapshots)
This task runs against the **pre-migration** ABS endpoints. Capture JSON now so we can diff after.
**Files:** none modified; output captured to `/tmp/abs_wire_*.json`.
- [ ] **Step 1: Identify the wire-shape contract**
The ABS HTTP handlers live in `internal/audiobooks/abs/`. They call the store adapters, which return Go structs. The structs (`abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, etc.) marshal to JSON via standard struct tags. The wire shape is fixed by those struct tag declarations.
Read them:
```bash
grep -n "type Collection struct\|type Playlist struct\|type SmartCollection struct\|type CollectionItem struct\|type PlaylistItem struct" internal/audiobooks/abs/*.go
```
For each struct, note every field's JSON tag. **This is your reference contract.** The rewritten store must populate every field with the same semantic content as today.
- [ ] **Step 2: Capture sample JSON from existing endpoints (if any data exists)**
The DB today has 1 abs_playlists row, 0 collections, 0 smart collections. Even one sample helps. Hit each endpoint via `curl` against the running silo and save the output:
```bash
# Adapt to your env's auth / port
PORT=8090 # silo's API port from .env
TOKEN=... # admin or user JWT
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/collections \
> /tmp/abs_wire_collections.json
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/playlists \
> /tmp/abs_wire_playlists.json
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/smart-collections \
> /tmp/abs_wire_smart_collections.json
```
If you don't have a token or the data is uninteresting (empty arrays), skip this step — the field-by-field reference from Step 1 is enough to write correct code.
- [ ] **Step 3: Note any non-obvious mappings**
For each adapter, write out the mapping between abs.* struct fields and `user_personal_collections` columns. Sample for Collection:
| `abs.Collection` field | Source column |
|---|---|
| `Id` | `user_personal_collections.id` |
| `Name` | `user_personal_collections.name` |
| `Description` | `user_personal_collections.description` |
| `UserId` | `user_personal_collections.user_id` |
| `LibraryId` | derived from `query_definition.library_ids[0]` (single library scope per collection) |
| `IsPublic` | `user_personal_collections.is_shared` |
| `CreatedAt` / `UpdatedAt` | timestamps as-is |
Similar table for Playlist and SmartCollection. **Write this out in a working note** — you'll reference it in Task 2.
No commit for this task.
---
## Task 2: Rewrite `ABSCollectionStore`
**Files:**
- Modify: `internal/audiobooks/abs_collection_store.go`
- Test: `internal/audiobooks/abs_collection_store_test.go` (create if missing)
The current 8 methods are: `ListUserCollections`, `GetCollection`, `CreateCollection`, `UpdateCollection`, `DeleteCollection`, `ListCollectionItems`, `AddCollectionItem`, `RemoveCollectionItem`.
For each, the rewrite swaps `FROM abs_user_collections` / `FROM abs_collection_items` for the canonical equivalents.
- [ ] **Step 1: Write a failing test for `ListUserCollections`**
Append to `internal/audiobooks/abs_collection_store_test.go` (create the file if absent, declaring `package audiobooks`):
```go
package audiobooks
import (
"context"
"testing"
// ...add the test DB harness import discovered by the next step
)
func TestABSCollectionStoreListUserCollections(t *testing.T) {
if testing.Short() {
t.Skip("requires test DB")
}
ctx := context.Background()
// Set up a test pool against migration head (>= 156).
pool := newTestPool(t)
defer pool.Close()
// Insert one user_personal_collections row with collection_type='manual',
// is_shared=true, library_ids=[9] in query_definition.
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id, query_definition)
VALUES
('test-c-1', 1, '', 'TestColl', 'desc', 'manual',
true, NOW(), NOW(), '', '{"library_ids":[9]}'::jsonb)
`)
if err != nil {
t.Fatalf("seed: %v", err)
}
store := &ABSCollectionStore{pool: pool}
got, err := store.ListUserCollections(ctx, "1", "")
if err != nil {
t.Fatalf("ListUserCollections: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d collections, want 1", len(got))
}
if got[0].Id != "test-c-1" || got[0].Name != "TestColl" || !got[0].IsPublic {
t.Errorf("unexpected fields: %+v", got[0])
}
}
```
**Verify the test-pool harness:** `grep -rln "func newTestPool\|func testPool" --include="*_test.go" .` finds the project's convention. If none exists, this test stays gated by `testing.Short()` and is best-effort — flag in the PR.
- [ ] **Step 2: Run the test to verify it fails**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
```
Expected: FAIL because `ListUserCollections` still queries the dropped `abs_user_collections` table.
- [ ] **Step 3: Rewrite `ListUserCollections`**
Replace the existing function body with:
```go
func (s *ABSCollectionStore) ListUserCollections(ctx context.Context, userID, profileID string) ([]abs.Collection, error) {
const q = `
SELECT
id, name, description, COALESCE(query_definition->'library_ids'->>0, '0')::int AS library_id,
user_id, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = 'manual'
AND user_id = $1::int
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
ORDER BY created_at DESC
`
rows, err := s.pool.Query(ctx, q, userID, profileID)
if err != nil {
return nil, fmt.Errorf("abs collection list: %w", err)
}
defer rows.Close()
var out []abs.Collection
for rows.Next() {
var c abs.Collection
if err := rows.Scan(&c.Id, &c.Name, &c.Description, &c.LibraryId, &c.UserId, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan abs collection: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
```
Adjust the `Scan` order to match `abs.Collection`'s actual field types — read the struct from `internal/audiobooks/abs/` to confirm. If `Id` is named `ID` per Go convention, fix accordingly.
- [ ] **Step 4: Run the test, verify it passes**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
```
Expected: PASS.
- [ ] **Step 5: Rewrite the remaining 7 methods using the same pattern**
For each method, apply this template:
| Method | Canonical operation |
|---|---|
| `GetCollection(id)` | `SELECT … WHERE id=$1 AND collection_type='manual'` |
| `CreateCollection(c)` | `INSERT INTO user_personal_collections (...) VALUES (..., 'manual', ..., jsonb_build_object('library_ids', jsonb_build_array(c.LibraryId)))` |
| `UpdateCollection(c)` | `UPDATE user_personal_collections SET name=$2, description=$3, is_shared=$4, query_definition=$5, updated_at=NOW() WHERE id=$1 AND collection_type='manual'` |
| `DeleteCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='manual'` (cascades to items via the existing FK) |
| `ListCollectionItems(collectionID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `media_item_id`→`abs.CollectionItem.LibraryItemId`, `sub_item_id`→`abs.CollectionItem.EpisodeId` |
| `AddCollectionItem(collectionID, libraryItemID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, '', COALESCE(MAX(position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
| `RemoveCollectionItem(collectionID, libraryItemID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=''` |
Each gets its own targeted test mirroring Step 1's structure: insert seed data, call the method, assert the canonical-table side-effect (`SELECT … FROM user_personal_collection_items …`).
For brevity, the plan shows the SQL above as a table; the actual implementation has each method as a complete function. Reuse the connection-error-wrapping style and the `fmt.Errorf("...: %w", err)` pattern already in the file.
- [ ] **Step 6: Run all tests for the file**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStore -v
```
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add internal/audiobooks/abs_collection_store.go internal/audiobooks/abs_collection_store_test.go
git commit -m "refactor(audiobooks): ABS collection store queries canonical tables
Rewrites ABSCollectionStore's 8 methods to read/write
user_personal_collections (collection_type='manual') instead of the
dropped abs_user_collections / abs_collection_items tables. Library
scope is now encoded in query_definition.library_ids.
ABS HTTP handlers consume the same method signatures; wire shape
is unchanged."
```
---
## Task 3: Rewrite `ABSPlaylistStore`
**Files:**
- Modify: `internal/audiobooks/abs_playlist_store.go`
- Test: `internal/audiobooks/abs_playlist_store_test.go` (create if missing)
Same pattern as Task 2. Methods: `ListUserPlaylists`, `GetPlaylist`, `coverArg`, `CreatePlaylist`, `UpdatePlaylist`, `DeletePlaylist`, `ListPlaylistItems`, `AddPlaylistItem`, `RemovePlaylistItem`.
Filter on `collection_type='playlist'` instead of `'manual'`.
**Special handling for `coverArg`:** `abs_playlists` has a `cover_item` foreign key to `media_items`; the canonical `user_personal_collections` has only `poster_url` (text). Per spec §6 open question, the design recommends dropping `cover_item` and regenerating poster URLs from the first item. For this rewrite:
- `coverArg` becomes a noop / removed: the new CreatePlaylist doesn't accept a cover_item field on insert.
- The wire-shape struct can keep its `CoverItem` field; the rewrite always serializes it as `""` (empty). If the ABS app surfaces a cover, it falls back to the first item's poster — same as the audiobook UI does today.
- [ ] **Step 1: Write a failing test for `ListUserPlaylists`**
```go
func TestABSPlaylistStoreListUserPlaylists(t *testing.T) {
if testing.Short() {
t.Skip("requires test DB")
}
ctx := context.Background()
pool := newTestPool(t)
defer pool.Close()
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
VALUES
('test-pl-1', 1, '', 'TestPlaylist', 'desc', 'playlist',
false, NOW(), NOW(), '')
`)
if err != nil { t.Fatalf("seed: %v", err) }
store := &ABSPlaylistStore{pool: pool}
got, err := store.ListUserPlaylists(ctx, "1", "")
if err != nil { t.Fatalf("ListUserPlaylists: %v", err) }
if len(got) != 1 || got[0].Id != "test-pl-1" || got[0].Name != "TestPlaylist" {
t.Errorf("unexpected: %+v", got)
}
}
```
- [ ] **Step 2: Run the test, fail, then rewrite**
```bash
go test ./internal/audiobooks/ -run TestABSPlaylistStoreListUserPlaylists -v
```
Expected: FAIL until rewrite lands.
- [ ] **Step 3: Rewrite `ListUserPlaylists`**
```go
func (s *ABSPlaylistStore) ListUserPlaylists(ctx context.Context, userID, profileID string) ([]abs.Playlist, error) {
const q = `
SELECT id, name, description, user_id, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = 'playlist'
AND user_id = $1::int
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
ORDER BY created_at DESC
`
rows, err := s.pool.Query(ctx, q, userID, profileID)
if err != nil { return nil, fmt.Errorf("abs playlist list: %w", err) }
defer rows.Close()
var out []abs.Playlist
for rows.Next() {
var p abs.Playlist
if err := rows.Scan(&p.Id, &p.Name, &p.Description, &p.UserId, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan abs playlist: %w", err)
}
// CoverItem deliberately left as zero value; see plan + spec §6.
out = append(out, p)
}
return out, rows.Err()
}
```
- [ ] **Step 4: Rewrite the remaining methods**
| Method | Canonical operation |
|---|---|
| `GetPlaylist(id)` | `SELECT … WHERE id=$1 AND collection_type='playlist'` |
| `CreatePlaylist(p)` | `INSERT … VALUES (..., 'playlist', ...)` — drop cover_item handling |
| `UpdatePlaylist(p)` | `UPDATE … SET name=$2, description=$3, is_shared=$4, updated_at=NOW() WHERE id=$1 AND collection_type='playlist'` |
| `DeletePlaylist(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='playlist'` |
| `ListPlaylistItems(playlistID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `sub_item_id`→`abs.PlaylistItem.EpisodeId` (empty string when no episode) |
| `AddPlaylistItem(playlistID, libraryItemID, episodeID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, $3, COALESCE(MAX(i.position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
| `RemovePlaylistItem(playlistID, libraryItemID, episodeID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=$3` |
| `coverArg(cover string)` | Delete entirely — no longer used. |
- [ ] **Step 5: Run all playlist tests**
```bash
go test ./internal/audiobooks/ -run TestABSPlaylistStore -v
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add internal/audiobooks/abs_playlist_store.go internal/audiobooks/abs_playlist_store_test.go
git commit -m "refactor(audiobooks): ABS playlist store queries canonical tables
Rewrites ABSPlaylistStore methods to read/write user_personal_collections
(collection_type='playlist') and user_personal_collection_items
(sub_item_id for episode-level entries). Drops the cover_item field
(no longer modeled in the canonical store; clients fall back to
first-item poster, same as audiobook UI today).
Wire shape preserved otherwise."
```
---
## Task 4: Rewrite `ABSSmartCollectionStore`
**Files:**
- Modify: `internal/audiobooks/abs_smart_collection_store.go`
- Test: `internal/audiobooks/abs_smart_collection_store_test.go` (create if missing)
Filter on `collection_type='smart'`. The rule DSL stored in `query_definition` (jsonb) — same column the silo-native query_definition uses. Note: the source column on `abs_smart_collections` was named `query_def` (not `query_definition`); the canonical column is `query_definition`. **Read+write target the canonical name.**
`color` and `is_pinned` from `abs_smart_collections` have no canonical equivalent (spec §6 defers them). The rewrite emits empty/false for those fields in the JSON output. If the ABS app silently relies on either, ship a follow-up to add the columns.
- [ ] **Step 1: Write a failing test**
```go
func TestABSSmartCollectionStoreListUserSmart(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t)
defer pool.Close()
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id, query_definition)
VALUES
('test-sc-1', 1, '', 'TestSmart', '', 'smart',
false, NOW(), NOW(), '', '{"rules":[]}'::jsonb)
`)
if err != nil { t.Fatalf("seed: %v", err) }
store := &ABSSmartCollectionStore{pool: pool}
got, err := store.ListUserSmartCollections(ctx, "1", "")
if err != nil { t.Fatalf("ListUserSmartCollections: %v", err) }
if len(got) != 1 || got[0].Id != "test-sc-1" {
t.Errorf("unexpected: %+v", got)
}
}
```
- [ ] **Step 2: Rewrite the 6 methods**
| Method | Canonical operation |
|---|---|
| `ListUserSmartCollections` | `SELECT id, name, description, query_definition, user_id, is_shared FROM user_personal_collections WHERE collection_type='smart' AND user_id=$1::int AND (profile_id=$2 OR ...) ORDER BY created_at DESC` |
| `GetSmartCollection(id)` | same with `WHERE id=$1 AND collection_type='smart'` |
| `CreateSmartCollection(c)` | `INSERT … 'smart' … query_definition=$N::jsonb` |
| `UpdateSmartCollection(c)` | `UPDATE … SET name=$2, description=$3, query_definition=$4, is_shared=$5, updated_at=NOW() WHERE id=$1 AND collection_type='smart'` |
| `DeleteSmartCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='smart'` |
| Materializer (if present) | Calls `internal/smartcoll.Evaluate(...)` against `media_items` filtered by `query_definition.library_ids` |
For `color` and `is_pinned`: read from `abs.SmartCollection` zero-value on read paths; ignore on write paths. If the wire shape unconditionally includes them, leave the zero values in place — clients receive `"color":"", "is_pinned":false`.
- [ ] **Step 3: Tests + commit**
Same shape as Tasks 2 and 3. Commit message:
```
refactor(audiobooks): ABS smart collection store queries canonical tables
Rewrites ABSSmartCollectionStore methods to read/write user_personal_collections
(collection_type='smart'). Rule DSL goes into query_definition (formerly
the abs_smart_collections.query_def column).
color and is_pinned have no canonical analog; emitted as zero values.
See spec §6 for the deferred decision on those columns.
```
---
## Task 5: Wire-shape regression test (snapshot diff)
**Files:**
- Add or extend: an integration test that drives the ABS handlers and asserts the JSON output for each endpoint hasn't changed.
If a snapshot/golden-file test framework already exists in the repo, use it (`grep -rln 'goldenfile\|snapshot' --include="*_test.go" internal/audiobooks/`). Otherwise this task is **manual verification** captured in the MR description:
- [ ] **Step 1: Seed identical fixtures pre- and post-rewrite**
Before the rewrite commits, seed one of each:
- An `abs_user_collections` row (pre-migration) with name "FixtureColl", 1 item.
- An `abs_playlists` row (pre-migration) with name "FixturePL", 1 item with sub_item.
- An `abs_smart_collections` row (pre-migration) with name "FixtureSmart", rules `{"any":[…]}`.
Capture the JSON response from each list endpoint. Save to `/tmp/wire_before_*.json`.
- [ ] **Step 2: Run migration 156, then seed equivalent rows in canonical tables**
Insert one of each `user_personal_collections` row with the same Id, Name, etc.
Capture the JSON response. Save to `/tmp/wire_after_*.json`.
- [ ] **Step 3: Diff**
```bash
for kind in collections playlists smart_collections; do
diff /tmp/wire_before_${kind}.json /tmp/wire_after_${kind}.json
done
```
Expected: empty diff for each. If anything differs, note the field and either patch the rewrite to match or flag it as a wire-shape regression in the PR description so the ABS team is aware.
- [ ] **Step 4: Commit the test (if framework supports it) or paste the diff into the PR description**
```bash
git add internal/audiobooks/abs_wireshape_test.go # if applicable
git commit -m "test(audiobooks): wire-shape snapshot for ABS adapters
Locks in JSON parity for /api/libraries/{id}/collections|playlists|
smart-collections after the canonical-table cutover."
```
---
## Verification (after merge)
1. Silo binary starts cleanly against a migration-156 DB.
2. ABS endpoints respond:
- `GET /api/libraries/9/collections` — returns the 0 manual collections, no errors.
- `GET /api/libraries/9/playlists` — returns the 1 promoted playlist with `Id` preserved.
- `GET /api/libraries/9/smart-collections` — returns 0 smart collections, no errors.
3. ABS Android app (or whatever client is in use) opens the library, sees the same content it saw pre-migration.
4. Creating a new collection from the ABS app inserts into `user_personal_collections`, not anywhere else:
```sql
SELECT collection_type, COUNT(*) FROM user_personal_collections GROUP BY 1;
```
Expected: counts increase for the relevant `collection_type` as the user creates content.
---
## Self-Review
**Spec coverage:**
- Collection store rewrite ✓ (Task 2)
- Playlist store rewrite ✓ (Task 3)
- Smart collection store rewrite ✓ (Task 4)
- Wire-shape preservation ✓ (Tasks 1, 5)
- Drop cover_item ✓ (Task 3, per spec §6)
- color / is_pinned deferred ✓ (Task 4, per spec §6)
**Placeholder scan:** Task 2 Step 5 lists 7 remaining methods as a table rather than spelling out the full Go body of each. The table gives the exact SQL semantics, the column mappings, and a pointer at the existing file's error-wrap style — sufficient for a competent implementer to write the bodies. The full code for each method would balloon this plan from ~700 to ~2000 lines; the table form is the right granularity. Same applies to Task 3 Step 4 and Task 4 Step 2. Each table entry could equally be a TDD task on its own; the implementer is free to expand any row into a write-test-fail-implement-pass-commit cycle.
**Type consistency:** `abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem` referenced consistently. `collection_type` enum values `'manual'`, `'playlist'`, `'smart'` consistent. `sub_item_id` column name consistent.
**Risk:** Largest sub-project of the four. Three files rewritten end-to-end. The biggest risk is wire-shape regressions where a struct field's serialized name differs subtly between adapters — Task 5 mitigates with the diff approach, but it's manual unless a snapshot harness exists. Sub-project 1 must land first; sub-project 2 must land first if Task 4 imports the new `internal/smartcoll` path.
@@ -0,0 +1,711 @@
# Collections Unification — Sub-project 4: Section Recipes for Audiobooks
> **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:** Make `page_sections` media-type-parameterized so existing recipes can include audiobooks, and add two new audiobook-flavored recipes (`continue_listening`, `by_audiobook_series`).
**Architecture:** Each recipe declaration in `internal/sections/recipes/` exposes a `SupportedMediaTypes` field. `PageSection` rows carry a `media_types text[]` column (added by migration 156 — sub-project 1). The fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes default to `['movie','series']` so current behavior is preserved.
**Tech Stack:** Go, PostgreSQL, existing `internal/sections/` + `internal/sections/recipes/` packages.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.4.
**Predecessor sub-project:** Sub-project 1 (migration 156) must land first — `page_sections.media_types` is required.
---
## File map
**Modify:**
- `internal/sections/types.go` — add `media_types []string` to the `PageSection` Go struct
- `internal/sections/registry.go` (or wherever recipes are registered — verify via grep) — surface `SupportedMediaTypes()` on the Recipe interface
- `internal/sections/recipes/*.go` — every recipe file declares its supported types
- `internal/sections/recipes/registry.go` — recipe registry exposes the supported-types info to the admin API
- Section fetchers (likely `internal/sections/fetcher.go` or similar) — wire `media_types` into the SQL `WHERE` clause
**Create:**
- `internal/sections/recipes/continue_listening.go` + test
- `internal/sections/recipes/by_audiobook_series.go` + test
**Add to admin API:**
- `internal/api/handlers/recipes.go` — the `GET /api/sections/recipes` endpoint surfaces the per-recipe supported-media-types in its response
---
## Task 1: Audit current recipe shape
This task is read-only — produces a working note for the rest of the plan.
- [ ] **Step 1: Map the current recipe interface**
```bash
grep -nE "type Recipe interface|type RecipeDefinition|func.*Type\(\) string|func.*Definition\(\)|SupportedMediaTypes" internal/sections/recipes/*.go internal/sections/types.go internal/sections/registry.go 2>/dev/null
```
Read the `Recipe` interface and any current registration code. Note:
- The exact method signatures recipes already implement.
- How a recipe is registered (`init()` calls? explicit `Register`? slice literal?).
- Whether `RecipeDefinition` carries metadata returned to the admin UI; that's where `SupportedMediaTypes` will surface.
- [ ] **Step 2: Map the fetcher's current SQL**
```bash
grep -nE "FetchOne|FetchSection|SELECT.*FROM media_items|type Fetcher" internal/sections/*.go 2>/dev/null | head -20
```
Find the function that turns a `ResolvedSection` into a list of `media_items`. Note its query shape — where the `WHERE` clause lives, what parameters it already takes.
- [ ] **Step 3: Note the section persistence shape**
```bash
grep -nE "INSERT INTO page_sections|UPDATE page_sections|SELECT.*FROM page_sections" internal/sections/*.go internal/api/handlers/sections*.go 2>/dev/null | head -10
```
Find where `page_sections` rows are read and written. The `media_types` column needs to be read/written there too.
No commit for this task — it produces a mental map you'll reference in Tasks 2–5.
---
## Task 2: Add `media_types` to the `PageSection` struct + serde
**Files:**
- Modify: `internal/sections/types.go` (`PageSection` struct)
- Modify: wherever `page_sections` rows are scanned and written (from Task 1 Step 3)
- [ ] **Step 1: Write failing test for `media_types` round-trip**
The test goes in whichever package owns the `PageSection` repository — likely `internal/sections/` itself. If a repository test file exists, append; otherwise create one:
```go
func TestPageSectionMediaTypesRoundTrip(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t) // adapt to project's test pool harness
repo := NewSectionRepository(pool)
sec := &PageSection{
Name: "Audiobook test",
RecipeType: "library_staples",
MediaTypes: []string{"audiobook"},
// ...fill in other required fields from the struct definition
}
if err := repo.Create(ctx, sec); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := repo.GetByID(ctx, sec.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if !reflect.DeepEqual(got.MediaTypes, []string{"audiobook"}) {
t.Errorf("MediaTypes = %v, want [audiobook]", got.MediaTypes)
}
}
```
Adapt the constructor name, struct fields, and DB harness to actual project shape (found in Task 1).
- [ ] **Step 2: Run the test to confirm failure**
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
Expected: FAIL — the `MediaTypes` field doesn't exist yet.
- [ ] **Step 3: Add the field to `PageSection`**
In `internal/sections/types.go`, find the `PageSection` struct (or whatever it's called per Task 1's audit) and add:
```go
type PageSection struct {
// ...existing fields
MediaTypes []string `json:"media_types" db:"media_types"`
}
```
If the project's struct tagging convention differs, follow that convention. The DB column is `text[]`; the Go type is `[]string`. pgx's default decoder handles this directly.
- [ ] **Step 4: Update the repository's read/write SQL**
For each `INSERT`/`UPDATE` against `page_sections`, add `media_types` to the column list and bind parameter list. For each `SELECT`, add it to the projection and scan into the new field.
Example pattern (adapt to actual code):
```go
const insertSQL = `
INSERT INTO page_sections (id, name, recipe_type, ..., media_types)
VALUES ($1, $2, $3, ..., $N::text[])
`
// ...
_, err := pool.Exec(ctx, insertSQL, sec.ID, sec.Name, sec.RecipeType, ..., sec.MediaTypes)
```
For SELECTs, add `media_types` to the projection and `&sec.MediaTypes` to the scan list.
- [ ] **Step 5: Run the test, verify pass**
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
Expected: PASS.
- [ ] **Step 6: Build + broader test**
```bash
go build ./...
go test ./internal/sections/ ./internal/api/handlers/ -short -timeout 90s
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add internal/sections/types.go internal/sections/*.go internal/api/handlers/sections*.go
git commit -m "feat(sections): expose page_sections.media_types in Go
Adds MediaTypes []string to PageSection + repo read/write paths.
Migration 156 (sub-project 1) added the underlying column with
DEFAULT ARRAY['movie','series']; existing rows retain that default,
preserving today's behavior."
```
Only stage the files you actually modified. Verify with `git diff --cached --stat` before commit.
---
## Task 3: Recipe interface — `SupportedMediaTypes`
**Files:**
- Modify: `internal/sections/recipes/registry.go` (interface declaration)
- Modify: every `*.go` recipe file in `internal/sections/recipes/` (each gains a one-line method)
- [ ] **Step 1: Find the Recipe interface**
```bash
grep -n "type Recipe " internal/sections/recipes/*.go internal/sections/*.go
```
Note the file + line of the interface declaration.
- [ ] **Step 2: Add a `SupportedMediaTypes()` method to the interface**
In whichever file declares the interface:
```go
type Recipe interface {
// ...existing methods
SupportedMediaTypes() []string
}
```
- [ ] **Step 3: Implement on every existing recipe**
For each `*Recipe` struct in `internal/sections/recipes/`, add the method. Existing recipes default to movies+series to preserve behavior:
```go
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series"} }
func (moodRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
func (handPickedRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
// ...and so on for every recipe in the directory
```
Use this enumeration as a checklist (from `ls internal/sections/recipes/*.go`, excluding tests):
- admin_curated_list
- custom
- discovery
- editorial
- hand_picked
- library_staples
- mood
- personalized
Each gets one method. Audiobook-eligible recipes — `library_staples`, `discovery`, `hand_picked`, `mood`, `personalized` — extend to include `"audiobook"` if their underlying query already handles all `media_items.type` values transparently. Conservative call: only `library_staples` extends to all three on this pass. The rest stay movies+series until each one's recipe-specific query is audited. **Bias toward the conservative default.**
Specifically:
```go
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series", "audiobook"} }
```
- [ ] **Step 4: Build**
```bash
go build ./...
```
Expected: clean. If any recipe is missed, the Go compiler will complain that the type doesn't satisfy the interface.
- [ ] **Step 5: Commit**
```bash
git add internal/sections/recipes/
git commit -m "feat(recipes): SupportedMediaTypes per recipe
Adds the SupportedMediaTypes() method to the Recipe interface and
implements it on each existing recipe. Conservative defaults preserve
today's behavior: only library_staples opens to audiobooks; others
stay movies+series until their underlying queries are audited."
```
---
## Task 4: Fetcher applies the `media_types` filter
**Files:**
- Modify: the section fetcher implementation file (found in Task 1 Step 2)
- [ ] **Step 1: Write a failing test**
Append to (or create) the fetcher's test file. Pattern:
```go
func TestFetcherFiltersByMediaTypes(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t)
// Seed: one movie, one audiobook, both eligible for the same recipe.
seedTestMediaItem(t, pool, "mov-1", "movie", "Test Movie")
seedTestMediaItem(t, pool, "ab-1", "audiobook", "Test Book")
fetcher := NewFetcher(pool)
t.Run("media_types movies only", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"movie"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "mov-1")
assertExcludes(t, got.Items, "ab-1")
})
t.Run("media_types audiobook only", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"audiobook"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "ab-1")
assertExcludes(t, got.Items, "mov-1")
})
t.Run("media_types both", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"movie", "audiobook"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "mov-1", "ab-1")
})
}
```
Adapt `ResolvedSection`, `FetchOne` signature, `seedTestMediaItem`, `assertContains` / `assertExcludes` to the project's actual shapes. The exact assertions depend on what `SectionWithItems` exposes — likely a `.Items []SectionItem` slice.
- [ ] **Step 2: Run the test, verify fail**
```bash
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
```
Expected: FAIL — `ResolvedSection.MediaTypes` field doesn't exist yet OR the fetcher SQL doesn't filter.
- [ ] **Step 3: Add `MediaTypes` to `ResolvedSection`**
In `internal/sections/types.go` (or wherever `ResolvedSection` lives):
```go
type ResolvedSection struct {
// ...existing fields
MediaTypes []string
}
```
The resolver code that builds `ResolvedSection` from a `PageSection` row needs one more line to pass `MediaTypes` through. Find it:
```bash
grep -n "ResolvedSection{" internal/sections/*.go internal/api/handlers/*.go
```
For each instance that builds one from a `PageSection`, add `MediaTypes: section.MediaTypes,`.
- [ ] **Step 4: Wire the filter into the fetcher SQL**
In the fetcher implementation, find the query that pulls `media_items`. Add to its `WHERE` clause:
```go
const query = `
SELECT ...
FROM media_items mi
WHERE ...
AND mi.type = ANY($N::text[])
...
`
// ...
rows, err := pool.Query(ctx, query, ..., mediaTypes)
```
If the fetcher dispatches to per-recipe resolvers, the filter goes inside each resolver's query. Most likely there's a shared query-building helper — find it via `grep -n "type = ANY\|recipe.*Resolve" internal/sections/`.
If the fetcher receives a `ResolvedSection` and dispatches to recipe resolvers based on `SectionType`, the resolvers each accept the `MediaTypes` slice as part of their `ResolverContext` (or whatever the recipe-side context type is). Add a field to that context type and thread it through.
**Validation rule**: the fetcher must reject a request where `ResolvedSection.MediaTypes` contains a type not in the recipe's `SupportedMediaTypes()`. Add this guard in the dispatch path:
```go
recipe, ok := registry.Get(resolved.SectionType)
if !ok { return result, fmt.Errorf("unknown recipe %q", resolved.SectionType) }
supported := setOf(recipe.SupportedMediaTypes())
for _, mt := range resolved.MediaTypes {
if _, ok := supported[mt]; !ok {
return result, fmt.Errorf("recipe %q does not support media type %q", resolved.SectionType, mt)
}
}
```
`setOf` is a 3-line helper that returns `map[string]struct{}` from a slice.
- [ ] **Step 5: Run the test, verify pass**
```bash
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
```
Expected: PASS for all three subtests.
- [ ] **Step 6: Commit**
```bash
git add internal/sections/
git commit -m "feat(sections): fetcher honors media_types filter
ResolvedSection carries a MediaTypes slice. Fetcher dispatch validates
the slice against the recipe's SupportedMediaTypes() and forwards it
into the underlying SQL (WHERE mi.type = ANY(\$N::text[]))."
```
---
## Task 5: New recipe — `continue_listening`
**Files:**
- Create: `internal/sections/recipes/continue_listening.go`
- Create: `internal/sections/recipes/continue_listening_test.go`
The audiobook analog of `continue_watching`. Surfaces audiobook items the user has progress on but hasn't finished.
- [ ] **Step 1: Read the existing `continue_watching` recipe (if present)**
```bash
ls internal/sections/recipes/ | grep -i continue
grep -rln "continue_watching\|user_watch_progress" internal/sections/recipes/ internal/sections/
```
Identify the closest analog. Copy its overall structure (registration, params struct, Resolve method shape). The audiobook version differs only in `type = 'audiobook'` filter and possibly which progress columns it inspects (audiobook progress may live in the same `user_watch_progress` table — verify with `\d user_watch_progress` against the DB).
- [ ] **Step 2: Write a failing test**
```go
package recipes
import (
"context"
"testing"
)
func TestContinueListeningRecipeRegistered(t *testing.T) {
r, ok := registry.Get("continue_listening")
if !ok {
t.Fatal("continue_listening recipe not registered")
}
got := r.SupportedMediaTypes()
want := []string{"audiobook"}
if !equalStringSlices(got, want) {
t.Errorf("SupportedMediaTypes = %v, want %v", got, want)
}
}
func TestContinueListeningResolvesProgressedAudiobooks(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
// Seed two audiobook items: one with progress > 0 and not finished;
// one with progress = 0. Run the recipe's Resolve method. Assert only
// the first is returned.
t.Skip("Implement once existing continue_watching test pattern is read")
}
```
- [ ] **Step 3: Implement the recipe**
```go
// Package recipes
package recipes
import (
"context"
"encoding/json"
"time"
)
type continueListeningRecipe struct{}
type ContinueListeningParams struct {
// e.g. max items, library scope; mirror continue_watching's shape
MaxItems int `json:"max_items"`
}
func (continueListeningRecipe) Type() string { return "continue_listening" }
func (continueListeningRecipe) NewParams() any { return &ContinueListeningParams{} }
func (continueListeningRecipe) DefaultCacheTTL() time.Duration { return 5 * time.Minute }
func (continueListeningRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
func (continueListeningRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
const q = `
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
FROM media_items mi
JOIN user_watch_progress uwp ON uwp.content_id = mi.content_id
WHERE mi.type = 'audiobook'
AND uwp.user_id = $1
AND uwp.position_seconds > 0
AND COALESCE(uwp.completed, false) = false
ORDER BY uwp.updated_at DESC
LIMIT $2
`
params := rc.Params.(*ContinueListeningParams)
limit := params.MaxItems
if limit <= 0 || limit > 50 { limit = 20 }
rows, err := rc.Pool.Query(rc.Ctx, q, rc.UserID, limit)
if err != nil { return ResolvedItems{}, fmt.Errorf("continue_listening: %w", err) }
defer rows.Close()
var items []ResolvedItem
for rows.Next() {
var it ResolvedItem
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
return ResolvedItems{}, fmt.Errorf("continue_listening scan: %w", err)
}
items = append(items, it)
}
return ResolvedItems{Items: items}, rows.Err()
}
func (continueListeningRecipe) Validate(raw json.RawMessage) error {
var p ContinueListeningParams
return json.Unmarshal(raw, &p)
}
func (continueListeningRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "continue_listening",
Name: "Continue Listening",
Description: "Audiobooks you've started but haven't finished.",
}
}
func init() {
registry.Register(continueListeningRecipe{})
}
```
**Adapt the field names** (`PosterPath`, `Pool`, `UserID`, `Ctx`, `Params`, etc.) to whatever the existing recipes use. **Adapt the SQL** to the actual `user_watch_progress` column names — verify with `\d user_watch_progress` against the DB.
- [ ] **Step 4: Run tests, verify pass**
```bash
go test ./internal/sections/recipes/ -run TestContinueListening -v
```
Expected: registration test PASS; the resolve test still skipped (or implemented and passing).
- [ ] **Step 5: Commit**
```bash
git add internal/sections/recipes/continue_listening.go internal/sections/recipes/continue_listening_test.go
git commit -m "feat(recipes): continue_listening audiobook rail
Audiobook analog of continue_watching. Returns books the user has
progress on but hasn't finished, sorted by most-recently-listened."
```
---
## Task 6: New recipe — `by_audiobook_series`
**Files:**
- Create: `internal/sections/recipes/by_audiobook_series.go`
- Create: `internal/sections/recipes/by_audiobook_series_test.go`
Surfaces books grouped by series, drawing from the `audiobook_series` table populated by the scanner.
- [ ] **Step 1: Write the registration test**
```go
func TestByAudiobookSeriesRecipeRegistered(t *testing.T) {
r, ok := registry.Get("by_audiobook_series")
if !ok { t.Fatal("by_audiobook_series not registered") }
if got := r.SupportedMediaTypes(); !equalStringSlices(got, []string{"audiobook"}) {
t.Errorf("SupportedMediaTypes = %v, want [audiobook]", got)
}
}
```
- [ ] **Step 2: Implement the recipe**
```go
type byAudiobookSeriesRecipe struct{}
type ByAudiobookSeriesParams struct {
SeriesName string `json:"series_name"`
}
func (byAudiobookSeriesRecipe) Type() string { return "by_audiobook_series" }
func (byAudiobookSeriesRecipe) NewParams() any { return &ByAudiobookSeriesParams{} }
func (byAudiobookSeriesRecipe) DefaultCacheTTL() time.Duration { return 30 * time.Minute }
func (byAudiobookSeriesRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
func (byAudiobookSeriesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
const q = `
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
FROM media_items mi
JOIN audiobook_series s ON s.content_id = mi.content_id
WHERE mi.type = 'audiobook'
AND s.series_name = $1
ORDER BY COALESCE(s.series_index, 9999), mi.title
`
params := rc.Params.(*ByAudiobookSeriesParams)
if params.SeriesName == "" {
return ResolvedItems{}, fmt.Errorf("by_audiobook_series: series_name required")
}
rows, err := rc.Pool.Query(rc.Ctx, q, params.SeriesName)
if err != nil { return ResolvedItems{}, fmt.Errorf("by_audiobook_series: %w", err) }
defer rows.Close()
var items []ResolvedItem
for rows.Next() {
var it ResolvedItem
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
return ResolvedItems{}, fmt.Errorf("by_audiobook_series scan: %w", err)
}
items = append(items, it)
}
return ResolvedItems{Items: items}, rows.Err()
}
func (byAudiobookSeriesRecipe) Validate(raw json.RawMessage) error {
var p ByAudiobookSeriesParams
if err := json.Unmarshal(raw, &p); err != nil { return err }
if p.SeriesName == "" { return fmt.Errorf("series_name is required") }
return nil
}
func (byAudiobookSeriesRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "by_audiobook_series",
Name: "By Audiobook Series",
Description: "Books in a specific audiobook series, ordered by series position.",
}
}
func init() {
registry.Register(byAudiobookSeriesRecipe{})
}
```
- [ ] **Step 3: Run test, verify pass**
```bash
go test ./internal/sections/recipes/ -run TestByAudiobookSeries -v
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/sections/recipes/by_audiobook_series.go internal/sections/recipes/by_audiobook_series_test.go
git commit -m "feat(recipes): by_audiobook_series rail
Surfaces books in a named series ordered by series_index. Backed by
the audiobook_series table populated by the scanner."
```
---
## Task 7: Surface `SupportedMediaTypes` in admin API response
**Files:**
- Modify: `internal/api/handlers/recipes.go` (`GET /api/sections/recipes` handler)
The admin section-builder UI needs to know which recipes support audiobooks so it can offer the media-types multi-select to the operator. The handler's response shape adds one field per recipe.
- [ ] **Step 1: Read the current handler**
```bash
cat internal/api/handlers/recipes.go
```
Note the response struct shape — probably `[]RecipeInfo` or similar with `Type`, `Name`, `Description`.
- [ ] **Step 2: Add `SupportedMediaTypes` to the response**
In the response struct definition, add:
```go
type recipeInfo struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
SupportedMediaTypes []string `json:"supported_media_types"`
}
```
In the handler body, set `SupportedMediaTypes: recipe.SupportedMediaTypes()`.
- [ ] **Step 3: Test**
If the project has a handler test for `GET /api/sections/recipes`, extend it. Otherwise the manual verification step covers this:
```bash
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '.[] | select(.type=="continue_listening")'
# Expected: { "type":"continue_listening", "name":"Continue Listening", ..., "supported_media_types":["audiobook"] }
```
- [ ] **Step 4: Commit**
```bash
git add internal/api/handlers/recipes.go
git commit -m "feat(api): expose supported_media_types in /api/sections/recipes
Admin UI section-builder uses this to gate the media-types
multi-select per recipe."
```
---
## Verification (after merge)
1. Existing sections continue to work — their `media_types` defaults to `['movie','series']` so query results are unchanged.
2. Creating a section with `media_types=['audiobook']` and `recipe_type='library_staples'` returns audiobook items.
3. Two new recipes registered:
```bash
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '[.[] | .type] | sort'
```
Expected: includes `"continue_listening"` and `"by_audiobook_series"` along with the existing types.
4. A user with active audiobook progress sees the `continue_listening` rail return their in-progress books.
5. The admin section-builder UI (if updated) lets the operator pick `media_types` for any recipe whose `SupportedMediaTypes` includes that type.
---
## Self-Review
**Spec coverage:**
- `page_sections.media_types` Go-side serde ✓ (Task 2)
- `Recipe.SupportedMediaTypes()` on every recipe ✓ (Task 3)
- Fetcher filter by `media_types` ✓ (Task 4)
- `continue_listening` recipe ✓ (Task 5)
- `by_audiobook_series` recipe ✓ (Task 6)
- Admin API exposes per-recipe types ✓ (Task 7)
**Placeholder scan:** Test bodies in Tasks 5 and 6 are partly stubbed with `t.Skip(...)` to be filled in by reading the existing `continue_watching` test pattern. That's a deliberate "read-existing-then-write" instruction, not a TBD. The other stubs (test pool harness lookups, schema verification queries) are explicit grep commands the implementer runs at start of the task. No abstract "implement appropriately."
**Type consistency:** `SupportedMediaTypes`, `MediaTypes`, `ResolverContext`, `ResolvedItems`, recipe `Type()` string values all consistent across tasks.
**Risk:** Lowest-risk of the 4 sub-projects. The fetcher change has an explicit failing-test guard (Task 4 Step 1) so regression is unlikely. The two new recipes are additive; even if one's SQL is mis-specified, existing recipes are unaffected. The main risk is the audit decision in Task 3 Step 3 — being too aggressive (extending too many recipes to audiobooks) could surface bad audiobook rails on existing sections; being too conservative wastes the parameterization. Conservative is the safer default.
@@ -0,0 +1,174 @@
# Audiobooks Stacked PR Split Implementation Plan
> **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:** Replace the oversized audiobook PR with a stacked series of smaller, reviewable PRs.
**Architecture:** Preserve the existing `feat/audiobooks` work as the source branch, then create branch cut points that expose one logical layer at a time. Each PR targets the previous branch so reviewers see only the incremental diff.
**Tech Stack:** Git, GitHub CLI, Go, pnpm/Vite, PostgreSQL migrations.
---
### Task 1: Close The Oversized PR
**Files:**
- Modify: GitHub PR #17 only.
- [ ] **Step 1: Post a replacement note**
Run:
```bash
gh pr comment 17 --repo Silo-Server/silo-server --body 'Closing this oversized PR in favor of a stacked series of smaller PRs. The existing branch is preserved as the source-of-truth while the stack is rebuilt into logical review units.'
```
Expected: GitHub prints the created comment URL.
- [ ] **Step 2: Close PR #17 without deleting the branch**
Run:
```bash
gh pr close 17 --repo Silo-Server/silo-server
```
Expected: PR #17 state becomes `CLOSED`; branch `feat/audiobooks` remains available.
### Task 2: Create Stacked Branches
**Files:**
- Modify: remote Git branches only.
- [ ] **Step 1: Create branch 1, foundation**
Branch name: `stack/audiobooks-foundation`
Scope:
- audiobook feature flag/settings
- audiobook and podcast schema foundations
- scanner support
- audiobook media item write path
- no native UI
- no ABS listener
- [ ] **Step 2: Create branch 2, native UI MVP**
Branch name: `stack/audiobooks-native-ui`
Base: `stack/audiobooks-foundation`
Scope:
- `/api/v1/audiobooks`
- audiobook detail/progress endpoints
- audiobook frontend route/sidebar/player MVP
- [ ] **Step 3: Create branch 3, ABS core**
Branch name: `stack/audiobooks-abs-core`
Base: `stack/audiobooks-native-ui`
Scope:
- ABS auth/login/refresh/logout
- ABS listener and feature-flag listener gating
- ABS library/items/play/progress
- ABS access control and security hardening
- [ ] **Step 4: Create branch 4, ABS collections**
Branch name: `stack/audiobooks-abs-collections`
Base: `stack/audiobooks-abs-core`
Scope:
- ABS bookmarks
- ABS collections
- ABS playlists
- ABS smart collections
- unified collection migration
- [ ] **Step 5: Create branch 5, extras and polish**
Branch name: `stack/audiobooks-extras`
Base: `stack/audiobooks-abs-collections`
Scope:
- podcasts/RSS
- stats
- author/series extras
- catalog audiobook filters/typeahead
- performance and enrichment cleanup
### Task 3: Open The Stacked PRs
**Files:**
- Modify: GitHub PRs only.
- [ ] **Step 1: Open PR 1**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base main --head stack/audiobooks-foundation --title 'feat(audiobooks): foundation and scanner support' --body 'First PR in the audiobook stack. Adds the schema/settings/scanner foundation without native UI or ABS compatibility.'
```
- [ ] **Step 2: Open PR 2**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-foundation --head stack/audiobooks-native-ui --title 'feat(audiobooks): native API and player MVP' --body 'Second PR in the audiobook stack. Adds native Silo audiobook endpoints and the MVP web player UI.'
```
- [ ] **Step 3: Open PR 3**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-native-ui --head stack/audiobooks-abs-core --title 'feat(audiobooks): Audiobookshelf compatibility core' --body 'Third PR in the audiobook stack. Adds ABS auth, listener, library/items/play/progress, and security hardening.'
```
- [ ] **Step 4: Open PR 4**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-core --head stack/audiobooks-abs-collections --title 'feat(audiobooks): ABS collections, playlists, and smart collections' --body 'Fourth PR in the audiobook stack. Adds ABS bookmarks, collections, playlists, smart collections, and unified collection storage.'
```
- [ ] **Step 5: Open PR 5**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-collections --head stack/audiobooks-extras --title 'feat(audiobooks): podcasts, catalog filters, and enrichment polish' --body 'Final PR in the audiobook stack. Adds podcast/RSS extras, audiobook catalog filters/typeahead, and scan/enrichment performance polish.'
```
### Task 4: Verify And Report
**Files:**
- Modify: none.
- [ ] **Step 1: Verify PR states**
Run:
```bash
gh pr list --repo Silo-Server/silo-server --state open --search 'audiobooks in:title' --json number,title,headRefName,baseRefName,url
```
Expected: five open stacked PRs with the base/head chain shown above.
- [ ] **Step 2: Run branch verification where feasible**
Run:
```bash
go test ./...
cd web && pnpm run build
```
Expected: both commands pass on the final stack tip.
@@ -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,447 @@
# Audiobook UI Redesign — Design Spec
**Date:** 2026-05-24
**Branch context:** `feat/audiobooks` in `silo-server`
**Status:** Approved — ready for implementation plan
**Related spec:** [`2026-05-24-audiobooks-absorption-design.md`](./2026-05-24-audiobooks-absorption-design.md)
## Goal
Bring the audiobook detail page and audiobook player to visual and
interaction parity with Silo's existing video player, translated for
audio. Today the audiobook surfaces work but feel generic, hide useful
information, and re-implement primitives the video player already has
in a polished form.
User-visible outcome:
- The audiobook player has the same two-mode shape as the video player
(compact HUD + immersive full-screen mode), so listening can be
background or foreground depending on intent.
- The detail page foregrounds the information audiobook listeners care
about — current chapter, narrator, listened progress — instead of
burying it.
- The audiobook-only affordances that don't exist today (sleep timer,
speed menu, bookmarks, narrator emphasis) have a home.
## Hard constraints
- **Reuse video-player primitives.** `SeekBar`, `ChaptersMenu`, and the
glass-disc button visual treatment all live in `web/src/player/` and
are already wired up correctly. The audiobook player must consume
them, not re-implement them.
- **One audio element.** Switching between mini and Now Listening must
not remount the audio element or restart the stream. Both modes are
chrome layered over the same playback state.
- **No new top-level routes.** The Now Listening view is an overlay on
whatever route the user is on, not a `/audiobooks/listen` page. This
mirrors how fullscreen video works — fullscreen is a mode, not a
destination.
- **Player state must be liftable.** The split between
`useAudiobookPlayback` (state hook) and `MiniBar` / `NowListening`
(chrome) must allow a future v1.1 change to lift the hook into an
app-level provider so the mini bar can survive page navigation.
v1 itself ships with the player tied to the detail route, same as
today.
## Scope
### In
- New mini-bar layout (cover tile, chapter title, glass-disc transport,
utility rail with sleep / chapters / speed / expand / close).
- New Now Listening full-overlay mode (large cover, chapter heading,
tall seek bar, sleep / chapters / speed / bookmark / car-mode row,
remaining-time toggle, overflow menu).
- Restructured audiobook detail page (progress + chapter-aware Resume
action, chapters expanded by default with currently-playing
highlight, narrator card, embedding-based "Similar audiobooks"
rail, "Also by author", "In this series").
- Extraction of `CircleButton` and a new `SpeedMenu` into
`web/src/player/components/` as shared primitives between video and
audiobook players.
- Split of today's monolithic `AudiobookPlayer.tsx` into a state hook
(`useAudiobookPlayback`) plus two chrome components (`MiniBar`,
`NowListening`) under `web/src/pages/audiobooks/player/`.
- Sleep timer (client-side only; pauses + short fade-out when the
timer fires).
### Out (deferred to v1.1)
- **Bookmarks.** UI affordances (mini-bar button, Now Listening
utility-row button, "Bookmarks (n)" detail-page action) ship
**hidden** in v1. Full feature needs `audiobook_bookmarks` table +
CRUD endpoints, which is its own sub-spec.
- **"Also by narrator" / cross-book "In this series" rails.** Render
only when the backend already exposes the data; otherwise hide.
Server-side joins to power them are a follow-up.
- **Car mode.** Hidden in v1; v1.1 will add a huge-buttons layout
variant reached from the utility row.
- **Audiobook library page redesign.** Out of scope; current grid
stays.
- **Persistent player across page navigation as a lifted React
context.** The current detail page owns the player state; lifting it
to an app-level provider so the bar survives navigating away from
the detail page is a follow-up. v1 ships with the bar tied to the
detail route, same as today, but visually and structurally ready
for that lift.
## Architecture
### Three connected surfaces
```
detail page ──opens──► mini bar ──expand──► Now Listening
▲ │
└─────── collapse ──────┘
```
- **Detail page** (`/audiobooks/book/:id`) is the landing surface.
Stays mounted while the player is open.
- **Mini bar** is a fixed bottom strip (current behavior) that appears
once the user clicks Play/Resume.
- **Now Listening** is a full-viewport overlay (z-index above the mini
bar, below modals) reached by clicking the cover-art tile or the
expand chevron in the mini bar. Dismissed via a collapse chevron.
The mini ↔ Now Listening transition uses a CSS view transition keyed
on the cover art element so the small tile appears to grow into the
big cover. The same primitive already used by `ViewTransitionLink`.
### Shared player primitives
`web/src/player/components/` already houses the visual building blocks
used by the video player. Two of them get extracted so both players
consume the same source of truth:
| File | Status | Purpose |
|---|---|---|
| `SeekBar.tsx` | reused as-is | already shared |
| `ChaptersMenu.tsx` | reused as-is | already shared |
| `CircleButton.tsx` | **new** — extracted from `PlayerControls.tsx` | the glass-disc primary/secondary button used in both transport clusters |
| `SpeedMenu.tsx` | **new** | popover speed menu matching the styling of `ChaptersMenu`; replaces the raw `<select>` today; video player can adopt later |
| `SleepTimerMenu.tsx` | **new** | popover with off / 5 / 15 / 30 / 45 / 60 / end-of-chapter; audiobook-only but lives here for symmetry |
Extracting `CircleButton` removes a divergence rather than adding a
layer — it's the kind of focused improvement that earns its keep
because today the video player and the audiobook player each render
their own visually-similar-but-not-identical disc buttons.
### Audiobook player component tree
```
web/src/pages/audiobooks/
AudiobookDetail.tsx (restructured per "Detail page" below)
AudiobookLibrary.tsx (unchanged)
player/ (new folder)
AudiobookPlayer.tsx (top level: owns audio element and state)
MiniBar.tsx (compact chrome)
NowListening.tsx (overlay chrome)
CoverExpandTile.tsx (left-edge tile inside MiniBar — also the view-transition anchor)
useAudiobookPlayback.ts (state hook: audio events, progress reporting, sleep timer)
```
`AudiobookPlayer` becomes a thin shell:
```tsx
function AudiobookPlayer(props) {
const playback = useAudiobookPlayback(props);
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
return (
<>
<audio ref={playback.audioRef} src={playback.streamUrl} preload="metadata" hidden />
{mode === "mini"
? <MiniBar playback={playback} onExpand={() => setMode("now-listening")} onClose={props.onClose} />
: <NowListening playback={playback} onCollapse={() => setMode("mini")} />}
</>
);
}
```
The audio element lives in the parent so a mode swap never touches it.
### useAudiobookPlayback responsibilities
Lifts today's monolithic `AudiobookPlayer.tsx` into a single hook
returning a stable shape:
```ts
{
audioRef, // ref<HTMLAudioElement>
streamUrl, // string
playing, currentTime, duration, buffered, rate,
chapters, // PlayerChapter[] (flattened across files)
currentChapter, // PlayerChapter | null
sleep, // { remainingMs: number | null, end: SleepEndCondition | null }
togglePlay, seekTo, skip,
setRate,
setSleep, // arms the timer
// bookmarks (v1.1 — initially returns []/no-op stubs)
bookmarks, addBookmark, removeBookmark,
}
```
The hook owns: audio event wiring, periodic progress reporting (the
existing 10s `useReportAudiobookProgress` cadence), sleep-timer state
+ fade-out, computing `currentChapter` from `currentTime`, and the
unmount-time pause + final report. The chrome components stay pure
presentational.
## Mini bar
Layout (one seek-bar row above a controls row):
```
[ full-width SeekBar with chapter ticks ]
[ cover tile | title + chapter + time | ◀30 ▶❚❚ 30▶ | ⌛ ☰ 1× ⌃ ✕ ]
↑ left col middle center right rail
36×54px truncating shared CircleButton cluster
sleep, chapters, speed,
expand chevron, close
(bookmark hidden in v1)
```
Differences from today's implementation:
- **CoverExpandTile** added at the left edge. 36×54 (portrait
aspect-2:3). On hover: subtle scale + a small "expand" chevron
overlay. Clicking it triggers the view transition to Now Listening.
- **Left text column** shows title (line 1) and current chapter title
(line 2). Time row stays where it is. Today the chapter title isn't
shown anywhere while playing — this is the single most useful
"where am I" cue and it's missing.
- **Center cluster** swaps the bespoke `CircleButton` defined inline
in the current `AudiobookPlayer` for the extracted shared one. Same
three controls (back 30 / play-pause / forward 30).
- **Right rail** replaces the raw `<select>` for speed with
`SpeedMenu` (popover, same styling as `ChaptersMenu`). Adds
`SleepTimerMenu`, an expand-up chevron (`ChevronUp`), and the
existing close `X`. The bookmark button is **hidden in v1** (mini
bar has no bookmark control until v1.1 lands).
Mini bar height is unchanged from today.
## Now Listening overlay
Full-viewport overlay, dark surface (`bg-background`). Z-index above
the mini bar, below modals.
Layout on desktop (width ≥ md):
```
┌─────────────────────────────────────────────────────────────────┐
│ ⌄ (collapse, top-left) ⋯ More (top-right) │
│ │
│ ┌──────────────────────┐ │
│ │ │ PROJECT HAIL MARY │
│ │ [ COVER ] │ Andy Weir │
│ │ ~360 × 540 │ Narrated by Ray Porter │
│ │ │ │
│ │ │ ── CHAPTER 7 ── │
│ │ │ The Astrophage │
│ └──────────────────────┘ │
│ │
│ [ tall SeekBar with chapter ticks ] │
│ 12:43 -4:08:35 │
│ │
│ ◀30 ▶❚❚ (large) 30▶ │
│ │
│ ⌛ Sleep ☰ Chapters (24) 1× Speed │
└─────────────────────────────────────────────────────────────────┘
```
Layout on mobile (portrait): same elements stacked top-to-bottom —
header row, cover (centered, smaller), metadata block, seek bar,
transport, utility row. No horizontal split.
Key behaviors:
- **Tap right time** toggles between `-remaining` and the wall-clock
end time. The toggle preference is in-memory only (resets per
session) — no persistence concern.
- **Sleep timer** opens `SleepTimerMenu`. When armed, the trigger
shows the countdown (`Sleep 04:32`). When the timer hits zero the
player fades volume to 0 over 5s and pauses.
- **Car mode** and **bookmark** buttons are **hidden in v1** — the
utility row ships with sleep / chapters / speed only. Car mode +
bookmark slots are documented here so v1.1 has a clear home for
them.
- **Overflow ⋯** menu items for v1: "Go to detail page", "Report a
sync problem" (opens a mailto/issue link). Future additions live
here.
The same `<audio>` element backs both modes, so all controls in Now
Listening are bound to the same `useAudiobookPlayback` state.
## Detail page restructure
The page keeps its current top-level shape (hero band + content
below) and adds/rearranges these blocks:
### Hero band
Today: `DetailHero` with a stack of `Play` / `Play from Start`
actions and a small progress bar that appears below the buttons only
when progress exists.
New version (still using `DetailHero` but with a richer `actions`
slot):
```
[ progress bar — full width of actions column, always shown if any progress ]
[ "3h 12m listened · 19%" caption ]
▶ Resume Ch 7 · The Astrophage ↻ Start Over 🔖 Bookmarks (3)
```
- Progress bar moves up *above* the buttons so the listener sees
position context first.
- Resume button label includes the chapter the listener will land
in. Pulled from the same `buildChapterList` already in the file.
- "Bookmarks (n)" button visible only when n > 0; opens a side sheet
listing them. v1 ships with an empty stub (always 0, button never
shown) — full feature in v1.1.
### Chapters section
Today: collapsed-by-default disclosure showing a chapter list.
New version:
- Expanded by default. (Collapsed in this position made sense when
the page was sparse; once we're foregrounding chapter awareness
everywhere else, hiding the list here is contradictory.)
- Currently-playing chapter row gets a left-edge accent and a
"▶ playing" badge on the right.
- Lightweight sort menu in the section header: "By position" (default),
"Longest first" — no UI for "By title" since chapter titles rarely
sort meaningfully. Sort preference is in-memory only for v1.
- Clicking a chapter row still calls `openPlayer(absoluteStart)` as
today; if the player is open, it seeks rather than reopens.
### Narrator card
New section between Chapters and the cross-rails. Renders only when
`data.narrator` is non-empty.
```
── NARRATOR ──
[ avatar 64×64 ] Ray Porter
47 audiobooks in your library →
```
Avatar source: if the absorbed audiobook data model exposes a
narrator photo, use it; otherwise an initials avatar in a circle.
The `→` link goes to a narrator detail page if one exists, or a
search-results page filtered by narrator name as a fallback.
### Cross-book rails
Three sections, each rendered only when its data is present, ordered
top-to-bottom:
- **"Similar audiobooks"** — embedding-based recommendations. Ranked
by vector similarity against this book; ordering and inclusion
decided server-side. Renders with a subtitle "Based on listening
patterns" to give the rail a small bit of provenance copy that the
metadata rails don't need.
- **"Also by {author}"** — horizontal scroller of cover tiles, same
pattern as `MediaRow` used elsewhere in Silo.
- **"In this series"** — same pattern, with "Book n of m" subtitle and
the current book non-clickable / highlighted.
All three rails ship behind feature-detection: if the API response
for the book includes the related array, render it; if not, hide the
section without showing a placeholder. This avoids blocking v1 on any
specific backend rail support — each lights up as the data lands.
### What's *not* in the new detail page
- Reviews / ratings — out of scope, not in the data model.
- Tabs — page is short enough that one scroll surface beats tabs.
- Social / cohost listening — no signal it's wanted.
## Data & API impact
Most of the work is pure frontend, but three small backend-adjacent
items to call out:
| Item | Impact |
|---|---|
| Sleep timer | Frontend-only. No API change. |
| Speed menu | Frontend-only — already a client-side audio setting. |
| Bookmarks | **Deferred to v1.1.** Needs `audiobook_bookmarks` table (`content_id`, `profile_id`, `position_seconds`, `note`, `created_at`) plus CRUD endpoints. v1 ships with the button hidden and `useAudiobookPlayback` exposing no-op bookmark stubs. |
| Narrator card with "n audiobooks in library" count | Needs narrator-aware query. If the detail endpoint already returns it, render; otherwise hide the count line and show only the name. |
| "Also by author" / "In this series" rails | Render only when arrays present. v1 doesn't require backend changes — it just adapts to whatever the response contains. |
| "Similar audiobooks" rail (embedding-based) | Render only when `similar_audiobooks` array present on the detail response. Backend computes similarity from per-book embeddings server-side and includes the top-N already ranked. v1 frontend has no embedding logic — it just renders whatever ordered list the API returns. |
## Routing & view-transition wiring
- `/audiobooks/book/:contentId` continues to be the only audiobook
route added by this work. Mini bar and Now Listening are layered
inside this route's component tree.
- View transition name: `audiobook-cover-{contentId}`. Set on the
cover tile inside `CoverExpandTile` and on the large cover element
inside `NowListening`. Same `contentId` on both = the browser
animates the rect change automatically.
- The detail page's hero cover and the mini bar's cover tile share
the same view-transition name so the *initial* open (clicking
Play/Resume on the hero) also animates the cover into the mini bar
position.
## Accessibility
- All transport controls have `aria-label`s (already true today; keep
parity).
- The expand chevron and the cover tile are both labeled "Expand
player" / "Open Now Listening" — two ways in, both discoverable to
screen readers.
- `SleepTimerMenu`, `SpeedMenu`, `ChaptersMenu` use the existing
`Popover` primitive with proper roving focus + Escape-to-close
(inherits from `ChaptersMenu` behavior).
- Now Listening overlay traps focus while open and restores focus to
the mini bar's expand chevron on close.
- Color contrast for the chapter-title text in the mini bar must
meet WCAG AA against the bar's background (use `text-foreground`
with a `text-muted-foreground` chapter title is the existing
pattern and passes).
## Testing
- **Unit:** `useAudiobookPlayback` — chapter computation from current
time across multi-file audiobooks, sleep-timer arm/disarm/fire,
progress reporting cadence and pause/seek/end triggers.
- **Component:** `MiniBar` and `NowListening` render expected
elements given a mocked playback object; expand/collapse calls the
right callbacks; speed menu / sleep menu open and emit the right
values.
- **Integration / Playwright** (if the repo has Playwright wired for
these flows): start playback from detail page → mini bar appears
with chapter title → click cover tile → Now Listening overlay
appears → seek bar works in both modes → collapse returns to mini
bar with playback uninterrupted (key assertion: `audio.currentTime`
monotonically advances across the mode swap).
## Out of scope (recap)
- Bookmarks backend (v1.1 sub-spec)
- Cross-book rails backend joins
- Car mode full layout
- Audiobook library grid redesign
- Lifting the player to an app-level provider so it survives
navigation away from the detail page
## Open questions / risks
- **Multi-file audiobooks.** Today's player only plays `files[0]` —
multi-file audiobooks display all chapters but can't actually cross
file boundaries. This redesign doesn't fix that; it inherits the
limitation. Worth flagging in the implementation plan whether to
fold a fix into v1 or leave it for a separate ticket.
- **View transition browser support.** CSS view transitions are
supported in modern Chromium and Safari Tech Preview; Firefox is
not there yet. The mini ↔ Now Listening swap must work without
the transition (graceful fallback to a plain unmount/mount).
- **Sleep-timer fade.** A 5s gain ramp on `HTMLAudioElement.volume`
works but is not as smooth as a Web Audio API gain node. Starting
simple; revisit if it feels janky.
@@ -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,262 @@
# ABS Bookmarks — Phase 1 Sub-Project 1
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** First sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Bookmarks bullet.
Commands in this document assume the repository root is the cwd.
## 1. Goal
Let an Audiobookshelf mobile client (official Android, official iOS, and well-behaved third parties such as Plappa) create, edit, and delete in-book bookmarks against a silo audiobook library, and see those bookmarks sync in real time to the user's other connected devices.
## 2. Non-goals
- No ebook bookmarks (audiobook only — matches ABS's own surface).
- No bulk bookmark import / export, no merge-by-title, no fuzzy time matching.
- No `/api/me/bookmarks` aggregate-list endpoint. The Android client only calls per-item endpoints; adding the aggregate would be dead code today.
- No client UI work — silo's web admin doesn't ship a bookmarks view yet, and the mobile clients already render bookmarks from these endpoints.
- No socket-server overhaul; Phase 1 just publishes events through the existing `Handler.publish` wrapper. Phase 2 covers the full socket event surface.
## 3. Architecture
Three ABS-compatible REST handlers + one socket event family + one migration.
- **HTTP layer:** new `internal/audiobooks/abs/bookmarks_handler.go` alongside existing `items_handler.go` / `progress.go`. Handler-method pattern mirrors `handleSetItemProgress`. Routes registered under both `/abs/api` and `/api` prefixes inside the existing bearerAuth group.
- **Storage layer:** new `BookmarkStore` interface in `internal/audiobooks/abs/bookmarks.go` (split out rather than crowd `progress.go`) plus a concrete `ABSBookmarkStore` Postgres implementation in `internal/audiobooks/abs_bookmark_store.go` — naming mirrors `abs_playback_session_store.go`. Wiring lives in `internal/audiobooks/service.go`, alongside the other store constructions in `BuildABSHandler`.
- **Migration:** `migrations/148_abs_bookmarks.up.sql` + `.down.sql`.
- **Socket events:** the existing nil-safe `Handler.publish(userID, event, payload)` wrapper. Each handler fires `user_updated` with a `reason` discriminator on success — three lines per handler. No new event publisher code.
## 4. Endpoint surface
All mounted at both `/abs/api/*` and `/api/*` inside the bearerAuth group.
| Verb | Path | Body | Returns |
|---|---|---|---|
| POST | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list |
| PATCH | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list (upserts at `time`) |
| DELETE | `/me/item/{itemId}/bookmark/{time}` | — | full item bookmark list (idempotent) |
All three handlers fire `user_updated` on success with the relevant `reason`:
| Handler | `reason` value |
|---|---|
| Create | `bookmark_created` |
| Update | `bookmark_updated` |
| Delete | `bookmark_deleted` |
Socket event payload shape:
```json
{
"reason": "bookmark_created",
"bookmark": { "id": "01K…", "libraryItemId": "126887…", "time": 1234.5, "title": "Cliffhanger", "createdAt": 1779786284823, "updatedAt": 1779786284823 }
}
```
For `bookmark_deleted`, `bookmark.title` is the pre-delete title; for `bookmark_created` / `bookmark_updated` it is the freshly-persisted row.
### 4.1 Wire shape
```json
{
"id": "01KSHR…",
"libraryItemId": "126887440911695876",
"time": 1234.5,
"title": "Good cliffhanger",
"createdAt": 1779786284823,
"updatedAt": 1779786284823
}
```
- All six fields always present (never `omitempty`).
- `time` is fractional seconds (`double`).
- `createdAt` / `updatedAt` are JS-epoch milliseconds (`int64`), matching every other ABS handler's timestamp emission.
- Empty title serializes as `"title": ""`.
### 4.2 Why no GET-list endpoint
The canonical continuum plugin handler, the Android `BookmarksModal.vue`, booklore-ng's per-item route, and the official ABS server all return the full item bookmark list from the mutating endpoints. A separate `GET /me/item/{id}/bookmark` would be dead code.
## 5. Data model
### 5.1 Migration 148_abs_bookmarks
`migrations/148_abs_bookmarks.up.sql`:
```sql
CREATE TABLE abs_bookmarks (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
library_item_id text NOT NULL,
time_seconds double precision NOT NULL,
title text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX abs_bookmarks_user_profile_item_time_uniq
ON abs_bookmarks (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_item_id,
time_seconds
);
CREATE INDEX abs_bookmarks_user_item_idx
ON abs_bookmarks (user_id, library_item_id);
```
`migrations/148_abs_bookmarks.down.sql`:
```sql
DROP TABLE abs_bookmarks;
```
### 5.2 Schema rationale
- **`id` text (ULID), not UUID** — matches every other ABS surface ID emitted by silo (`abs_tokens.id`, `abs_playback_sessions.id`). Clients get a consistent shape; ULIDs are also lexicographically sortable by creation time, which simplifies debug output.
- **`profile_id` nullable, with COALESCE sentinel in the unique index** — silo's primary-profile convention is `NULL` profile. Postgres treats raw `NULL` as distinct for uniqueness, so the COALESCE-to-fixed-UUID trick collapses NULL to a single bucket per user, which is what makes "one bookmark per timestamp per profile" work.
- **Unique on `(user, profile, item, time)`** — encodes the "one bookmark per timestamp" invariant directly, which is what makes `Upsert(...,time,title)` the natural primitive: PATCH at an existing time updates the title; PATCH at a new time creates.
- **Secondary index on `(user_id, library_item_id)`** — covers the hot-path `List` query that runs after every mutation. Avoids a sort or full scan when a user has many bookmarks across many items.
- **`title` NOT NULL DEFAULT ''** — Android lets users create quick bookmarks without a title; empty string stores cleanly. Avoids null-guard branches in client iteration code.
### 5.3 Go model
```go
type Bookmark struct {
ID string // ULID
LibraryItemID string
Time float64 // fractional seconds
Title string
CreatedAt time.Time
UpdatedAt time.Time
}
```
Handlers convert to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
## 6. Storage contract
```go
type BookmarkStore interface {
// List returns all bookmarks for (user, profile, item) ordered by time
// ASC. Empty slice (never nil) when none exist.
List(ctx context.Context, userID, profileID, itemID string) ([]Bookmark, error)
// Upsert inserts a bookmark or updates the title at the exact
// (user, profile, item, time) tuple. ID is generated on insert and
// preserved on update. Returns the resulting row.
Upsert(ctx context.Context, userID, profileID, itemID string, time float64, title string) (Bookmark, error)
// Delete removes the bookmark at (user, profile, item, time). Returns
// nil when no row matched — DELETE is idempotent (a UX convenience,
// not a 404 surface).
Delete(ctx context.Context, userID, profileID, itemID string, time float64) error
}
```
**Behavior:**
- `Upsert` uses a single `INSERT ... ON CONFLICT (user_id, COALESCE(profile_id,'…'), library_item_id, time_seconds) DO UPDATE SET title = EXCLUDED.title, updated_at = now() RETURNING *` — one round-trip, no read-then-write race.
- `List` sort happens in SQL (`ORDER BY time_seconds ASC`), not in Go.
- Profile mapping: handlers pass `a.ProfileID` (string, empty = primary) straight through to the store; the store maps empty → NULL on writes and uses the same COALESCE-to-sentinel-UUID trick on reads. Mirrors the pattern already used by `abs_session_store.go` (`*string` round-trip) — pick whichever pgx approach reads cleanest in the impl, just keep the empty-as-primary convention consistent with the rest of `internal/audiobooks/`.
- Time precision: exact float64 equality on PATCH/DELETE-at-time. Plappa and the official client always echo back the exact value they received, so epsilon comparison would be complexity nothing exercises.
- `Delete` on a non-existent row returns `nil`. The caller still re-fetches the list and returns it, so double-tapped deletes produce consistent state.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| `time` missing or NaN | 400 | `time required` |
| `itemId` not in `MediaStore` | 404 | `item not found` |
| Store upsert / mutate fails | 500 | sanitized `bookmark persist failed`; err logged via `slog.Error` |
| Store delete fails (DB error, not missing row) | 500 | sanitized `bookmark delete failed` |
| List fetch fails after a successful mutation | 200 + empty `[]` + `slog.Warn` | mutation already committed; returning empty list beats 500 |
**Cross-cutting:**
- **No leaked-existence side channel.** DELETE for a bookmark belonging to another user/profile returns 200 with the caller's list (which doesn't contain the target) — indistinguishable from DELETE for a non-existent bookmark. No 403, no enumeration vector.
- **Item validation.** Validate `itemId` via `MediaStore.GetAudiobookByID` before touching the store (same pattern as `handleItem`). Catches typos and deleted items early; avoids orphan bookmark rows whose item no longer exists.
- **Body size limit.** Request body wrapped in `io.LimitReader(r.Body, 1<<20)` — same 1 MiB cap as `handleStandaloneLogin`.
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
## 8. Testing
### 8.1 Unit tests — `bookmarks_handler_test.go`
In-memory fake `BookmarkStore` (mirrors `memTokenStore` / `fakePlaybackSessionStore` pattern already used in this package).
| Test | Asserts |
|---|---|
| `Create_NewBookmark_ReturnsListContainingIt` | POST → 200, response is `[{id, time, title, …}]`, list length 1, fields populated |
| `Create_TwoAtDifferentTimes_ListOrderedByTime` | POST × 2 (times 100 and 50), list returned `[{time:50},{time:100}]` |
| `Upsert_SameTime_UpdatesTitleNoDuplicate` | POST then PATCH at same time with new title → list length 1, title updated, id preserved |
| `Delete_ExistingBookmark_RemovedFromList` | POST then DELETE same time → 200, list empty |
| `Delete_NonExistentTime_IdempotentReturnsEmptyList` | DELETE without prior POST → 200, empty list |
| `Delete_OtherUserBookmark_NoOpAndNoExistenceLeak` | seed bookmark for user B, user A DELETEs same item+time → 200, user B's bookmark untouched |
| `ProfileIsolation_BookmarksScopedPerProfile` | (user, profile A) POST → (user, profile B) List returns empty |
| `MissingItem_404` | POST against unknown itemId → 404 |
| `InvalidBody_400` | POST with malformed JSON → 400, no DB write |
| `MissingTime_400` | POST with body `{title:"x"}` (no time) → 400 |
| `SocketEvent_FiredOnCreate` | mock `EventPublisher` captures the publish; assert userID + `user_updated` + `reason:"bookmark_created"` |
| `SocketEvent_FiredOnUpdate` | same for `bookmark_updated` |
| `SocketEvent_FiredOnDelete` | same for `bookmark_deleted` |
### 8.2 Wire-shape test — `bookmarks_envelope_test.go`
One marshal test that takes a `Bookmark` (including the empty-title case) and asserts every required JSON key is present and uses the camelCase spelling: `id`, `libraryItemId`, `time`, `title`, `createdAt`, `updatedAt`. Mirrors the existing `TestSiloItemToMetadata_JSONKeysAlwaysPresent` and `TestLoginEnvelope_HasRequiredKeys` patterns.
### 8.3 Live integration smoke (post-deploy)
```
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' -H 'x-return-tokens: true' \
-d '{"username":"<u>","password":"<p>"}' http://127.0.0.1:13378/login \
| python3 -c "import sys,json;print(json.load(sys.stdin)['accessToken'])")
ITEM=$(curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:13378/api/libraries/<lid>/items?limit=1 \
| python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['id'])")
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"smoke","time":42.5}' \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"updated","time":42.5}' \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark/42.5 | python3 -m json.tool
```
Expected: each call returns the updated list; the final DELETE returns `[]`.
### 8.4 Explicitly out-of-scope
- No real-Postgres integration test for the SQL. The `ON CONFLICT` clause and unique-index COALESCE behavior are small enough that the in-memory fake covers the semantics; CI does not currently run a Postgres for the ABS subdomain.
- No load test. Bookmark mutations are user-driven and rare (handful per session).
## 9. Risks & open questions
- **Time-precision collision risk.** If a future client rounds `time` differently between POST and DELETE (e.g. POST sends `42.500001`, DELETE sends `42.5`), the DELETE would silently no-op. Acceptable for the current client set (Android and official iOS echo exactly), but worth a comment on the `Delete` method so future-us doesn't burn time chasing a ghost.
- **No migration rollback test.** The `.down.sql` is one `DROP TABLE` — low risk — but the migration suite doesn't currently exercise rolls. Same posture as recent migrations; not a blocker.
- **Bookmark fan-out on socket events.** `Handler.publish(userID, ...)` targets the user room. If the user has many connected sockets, the publish fans out per-socket. Volume here is trivial (manual user action). No backpressure concern at Phase 1 scale.
## 10. Out-of-scope follow-ups
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
- Aggregate `GET /api/me/bookmarks` (caller's full bookmark inventory). Adds value only when a "Bookmarks" tab lands in a client that needs it.
- Hydrating `user.bookmarks` in the `/login` envelope. Currently emits `[]` placeholder; populating costs a join on every login and only matters to clients that read from there at startup.
- Embedding `bookmarks` on item-detail (`GET /api/items/{id}`). The Android player fetches its bookmarks via the per-item bookmark endpoint on modal-open, not from item-detail.
## 11. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Wire-shape reference (canonical, working against real ABS Android): the continuum-plugin-audiobooks `bookmarks_handler.go` + the `r.Post/Patch/Delete` mounts in its `handler.go`. Diff against silo before flagging response-shape concerns.
- Client wire usage: `audiobookshelf-app/components/modals/BookmarksModal.vue` (the only place the official client builds bookmark requests).
- Booklore-ng has its own `me/bookmarks` aggregate endpoint that the official client does not use; ignored here.
@@ -0,0 +1,523 @@
# ABS Collections + Playlists — Phase 1 Sub-Project 2
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Second sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Manual Collections + Playlists bullets.
**Sibling sub-project:** `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md` (sub-project 1; the bookmarks data model, in-memory test fakes, store wiring, and error-handling conventions established there are reused here without re-justification).
Commands in this document assume the repository root is the cwd.
## 1. Goal
Land manual user collections and ordered playlists on the silo audiobook surface so the official Audiobookshelf Android, iOS, and Plappa clients can create, browse, mutate, and share named groupings of audiobooks. "Manual collection" means a user-curated unordered set of audiobooks with a name + description (think: "Favorites", "To Read"). "Playlist" means an ordered queue with a cover image (think: "Wind-down listening"). Both are owned by a profile, optionally public to other users on the same silo instance.
## 2. Non-goals
- **No podcast episode hydration.** Playlist items accept and echo `episodeId`, but only audiobook items are looked up in `MediaStore`. A future sub-project will wire `internal/audiobooks/podcastfeed` hydration.
- **No reorder API.** Clients simulate reorder via remove+add (the re-added item lands at the end).
- **No smart collections.** Those are sub-project 3 (separate `query_def` storage + DSL evaluator).
- **No silo-web UI.** The web admin doesn't ship a collections/playlists view yet; ABS mobile clients render these.
- **No socket-server overhaul.** Playlists publish three existing-pattern events; collections publish none. Full socket parity is Phase 2 of the parent spec.
## 3. Architecture
Two REST surfaces sharing a uniform shape, mounted under `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group.
- **HTTP layer:**
- `internal/audiobooks/abs/collections_handler.go` — 7 routes (list, create, get, update, delete, add item, remove item).
- `internal/audiobooks/abs/playlists_handler.go` — 9 routes (list, create, get, update, delete, add single item, batch add, batch remove, remove single item; remove-episode variant uses two URL params).
- **Storage layer:**
- New interfaces in `internal/audiobooks/abs/collections.go` and `playlists.go` (one file each, parallel to `bookmarks.go`):
- `CollectionStore` — `ListUserCollections / GetCollection / CreateCollection / UpdateCollection / DeleteCollection / ListCollectionItems / AddCollectionItem / RemoveCollectionItem`.
- `PlaylistStore` — `ListUserPlaylists / GetPlaylist / CreatePlaylist / UpdatePlaylist / DeletePlaylist / ListPlaylistItems / AddPlaylistItem / RemovePlaylistItem`.
- Concrete pgx impls in `internal/audiobooks/abs_collection_store.go` and `abs_playlist_store.go` (parallel to `abs_bookmark_store.go`).
- **Migrations** (paired up/down per `CLAUDE.md`):
- `149_abs_user_collections`, `150_abs_collection_items`, `151_abs_playlists`, `152_abs_playlist_items`.
- **Socket events:** Playlists publish `playlist_added` / `playlist_updated` / `playlist_removed` (continuum-canonical event names). Collections publish nothing. All publishes go through the existing nil-safe `Handler.publish(...)` wrapper.
- **Service wiring:** Both new stores wired in `BuildABSHandler` (mirrors the BookmarkStore wiring landed in sub-project 1).
## 4. Endpoint surface
All routes mounted under both `/abs/api/*` and `/api/*` inside `bearerAuth`. Success status code is **200 OK** in every row of the tables below, except where the `Returns` column explicitly says **204 No Content** (DELETE collection / DELETE playlist themselves; the item-mutation DELETEs return 200 with the parent's full-shape body, matching continuum).
### 4.1 Collections (7 routes)
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/collections` | — | `{"collections": [Collection list-shape]}` |
| POST | `/collections` | `{name, description, isPublic?}` | Collection full-shape |
| GET | `/collections/{id}` | — | Collection full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
| PATCH | `/collections/{id}` | `{name?, description?, isPublic?}` | Collection full-shape |
| DELETE | `/collections/{id}` | — | 204 No Content |
| POST | `/collections/{id}/book/{bookId}` | — | Collection full-shape (with updated `books[]`) |
| DELETE | `/collections/{id}/book/{bookId}` | — | Collection full-shape |
### 4.2 Playlists (9 routes)
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/playlists` | — | `{"playlists": [Playlist list-shape]}` |
| POST | `/playlists` | `{name, description, cover_item?, isPublic?}` | Playlist full-shape |
| GET | `/playlists/{id}` | — | Playlist full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
| PATCH | `/playlists/{id}` | `{name?, description?, cover_item?, isPublic?}` | Playlist full-shape |
| DELETE | `/playlists/{id}` | — | 204 No Content |
| POST | `/playlists/{id}/item` | `{libraryItemId, episodeId?}` | Playlist full-shape |
| POST | `/playlists/{id}/batch/add` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
| POST | `/playlists/{id}/batch/remove` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
| DELETE | `/playlists/{id}/item/{libraryItemId}` | — | Playlist full-shape |
| DELETE | `/playlists/{id}/item/{libraryItemId}/{episodeId}` | — | Playlist full-shape |
### 4.3 Wire shape — Collection
**List shape (no `books[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "Favorites",
"description": "My top picks",
"isPublic": false,
"lastUpdate": 1779786284823,
"createdAt": 1779786284823
}
```
**Full shape (with `books[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "Favorites",
"description": "My top picks",
"isPublic": false,
"lastUpdate": 1779786284823,
"createdAt": 1779786284823,
"books": [
{
"id": "126887...",
"libraryId": "9",
"media": { "metadata": { "title": "Book Title", "authors": [...] } }
}
]
}
```
All seven top-level keys always present (`description` is `""` when unset, never omitted — fixes the continuum-reference bug where description was always emitted as empty regardless of stored value). `books[]` is always an array (possibly empty) on the full shape; omitted on the list shape.
### 4.4 Wire shape — Playlist
**List shape (no `items[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "My Queue",
"description": "",
"isPublic": false,
"coverPath": "126887...",
"createdAt": 1779786284823,
"lastUpdate": 1779786284823
}
```
**Full shape (with `items[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "My Queue",
"description": "",
"isPublic": false,
"coverPath": "126887...",
"createdAt": 1779786284823,
"lastUpdate": 1779786284823,
"items": [
{ "libraryItemId": "126887...", "position": 0, "title": "Book Title", "libraryId": "9" },
{ "libraryItemId": "podcast-x", "episodeId": "ep-1", "position": 1 }
]
}
```
`coverPath` is emitted when set; omitted when unset. `description` always present. Items always sorted by `position` ASC. Entries with `episodeId` set are emitted with the field; audiobook entries omit it. Audiobook items hydrate `title` + `libraryId` via `MediaStore.GetAudiobookByID`; episode items emit the bare reference.
### 4.5 Hydration semantics
- `MediaStore.GetAudiobookByID(libraryItemId)` is called per book on the **full** shape only (the list shape skips it to keep response sizes small).
- On miss (item deleted between collection/playlist mutation and list-fetch), the entry degrades to `{id, libraryId}` only — clients render a placeholder.
- `libraryId` resolution: same `resolveDefaultLibrary` helper from `items_handler.go` (returns numeric library ID as decimal string).
### 4.6 Socket events
| Event | When | Payload |
|---|---|---|
| `playlist_added` | After POST `/playlists` | `{id, name}` |
| `playlist_updated` | After PATCH or any item mutation (single or batch) | `{id}` |
| `playlist_removed` | After DELETE `/playlists/{id}` | `{id}` |
Collections fire no socket events (matches continuum; Phase 2 of the parent spec covers full event parity).
## 5. Data model
### 5.1 Migration 149 — `abs_user_collections`
`migrations/149_abs_user_collections.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_user_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_user_collections_user_profile_idx
ON public.abs_user_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/149_abs_user_collections.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_user_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_user_collections;
```
### 5.2 Migration 150 — `abs_collection_items`
`migrations/150_abs_collection_items.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_collection_items (
collection_id text NOT NULL REFERENCES public.abs_user_collections(id) ON DELETE CASCADE,
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collection_id, library_item_id)
);
CREATE INDEX IF NOT EXISTS abs_collection_items_library_item_idx
ON public.abs_collection_items (library_item_id);
```
`migrations/150_abs_collection_items.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_collection_items_library_item_idx;
DROP TABLE IF EXISTS public.abs_collection_items;
```
### 5.3 Migration 151 — `abs_playlists`
`migrations/151_abs_playlists.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_playlists (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
cover_item text REFERENCES public.media_items(content_id) ON DELETE SET NULL,
is_public boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_playlists_user_profile_idx
ON public.abs_playlists (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/151_abs_playlists.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_playlists_user_profile_idx;
DROP TABLE IF EXISTS public.abs_playlists;
```
### 5.4 Migration 152 — `abs_playlist_items`
`migrations/152_abs_playlist_items.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_playlist_items (
playlist_id text NOT NULL REFERENCES public.abs_playlists(id) ON DELETE CASCADE,
library_item_id text NOT NULL,
episode_id text NOT NULL DEFAULT '',
position integer NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (playlist_id, library_item_id, episode_id)
);
CREATE INDEX IF NOT EXISTS abs_playlist_items_playlist_position_idx
ON public.abs_playlist_items (playlist_id, position);
```
`migrations/152_abs_playlist_items.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_playlist_items_playlist_position_idx;
DROP TABLE IF EXISTS public.abs_playlist_items;
```
### 5.5 Schema rationale
- **`is_public boolean`** — cross-user-public read semantics. `false` default. List endpoints never expose other users' rows; GET-by-id allows non-owner reads only when `is_public = true`.
- **Collection items: FK + CASCADE on `library_item_id`** — when a book is deleted from the library, drop it from all collections (clean orphan-free state). Same pattern as migration 143's `abs_playback_sessions.content_id`.
- **Collection items: composite PK on `(collection_id, library_item_id)`** — enforces "a book appears at most once in a collection" without a synthetic ID.
- **Playlist items: `library_item_id` NOT FK'd** — collections enforce one-shot membership; playlists are ordered queues that may legitimately reference items the user hasn't bookmarked. Decoupling lets a future migration FK it when episode support lands properly. Today: handler validates via MediaStore (same pattern as bookmarks).
- **Playlist items: `episode_id text NOT NULL DEFAULT ''`** — empty string for audiobook items, populated for podcast episodes. Empty-string-default lets `(playlist_id, library_item_id, episode_id)` be a clean unique key without COALESCE.
- **Playlist items: no synthetic ID** — `(playlist_id, library_item_id, episode_id)` is naturally unique; `position` is sort hint (gaps allowed when items are removed).
- **`cover_item REFERENCES media_items(content_id) ON DELETE SET NULL`** — playlist survives cover-item deletion; cover gracefully becomes nothing.
### 5.6 Go models
```go
type Collection struct {
ID string
UserID string
ProfileID string
Name string
Description string
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CollectionItem struct {
CollectionID string
LibraryItemID string
AddedAt time.Time
}
type Playlist struct {
ID string
UserID string
ProfileID string
Name string
Description string
CoverItem string // empty when unset
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistItem struct {
PlaylistID string
LibraryItemID string
EpisodeID string // empty for audiobook items
Position int
AddedAt time.Time
}
```
Handlers convert these to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
## 6. Storage contract
### 6.1 `CollectionStore` (in `internal/audiobooks/abs/collections.go`)
```go
type CollectionStore interface {
ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error)
GetCollection(ctx context.Context, id string) (Collection, error)
CreateCollection(ctx context.Context, c Collection) error
UpdateCollection(ctx context.Context, c Collection) error
DeleteCollection(ctx context.Context, id string) error
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
}
```
### 6.2 `PlaylistStore` (in `internal/audiobooks/abs/playlists.go`)
```go
type PlaylistStore interface {
ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error)
GetPlaylist(ctx context.Context, id string) (Playlist, error)
CreatePlaylist(ctx context.Context, p Playlist) error
UpdatePlaylist(ctx context.Context, p Playlist) error
DeletePlaylist(ctx context.Context, id string) error
ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error)
AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
}
```
### 6.3 Behavior
- **List ordering:** Collections list ordered by `created_at DESC`. Playlists list ordered by `created_at DESC`. Playlist items ordered by `position ASC`. Collection items ordered by `added_at ASC`. All sorting happens in SQL, not in Go.
- **Empty results:** All List methods return an empty slice (never nil) when no rows match.
- **`GetCollection` / `GetPlaylist`:** Return `ErrNotFound` when absent. NO owner check — caller authorizes via the response's `UserID` and `IsPublic`.
- **`AddPlaylistItem` position assignment:** Single SQL statement computing `position = COALESCE(MAX(position), 0) + 1 WHERE playlist_id = $1` inside the INSERT. No read-before-write race.
- **`AddCollectionItem` / `AddPlaylistItem` idempotency:** `INSERT ... ON CONFLICT (...) DO NOTHING`. Re-adding an existing tuple is a silent no-op (no error, no row mutation, no `updated_at` bump beyond what the handler does separately).
- **`RemoveCollectionItem` / `RemovePlaylistItem` idempotency:** Return `nil` on no-match.
- **Profile mapping:** Handlers pass `a.ProfileID` straight through (empty = primary). Store uses `profileArg` helper (returns `nil` for empty, the string otherwise). `COALESCE(profile_id, '00000000-...'::uuid)` in WHERE clauses on both column and bind value, mirroring the BookmarkStore convention.
- **`updated_at` bump on item mutations:** Both `Add*Item` and `Remove*Item` execute the item mutation and an `UPDATE abs_user_collections SET updated_at = now() WHERE id = $1` (or `abs_playlists`) in the same transaction. Wire field `lastUpdate` reflects this.
- **Cascade cleanup:** Deleting a collection drops all its `abs_collection_items` via FK CASCADE. Deleting a playlist drops all its `abs_playlist_items`. Deleting a `media_items` row drops it from all `abs_collection_items` (via FK CASCADE); playlists surface stale references on the next List, where the handler degrades to bare-id entries.
### 6.4 Cross-user public read
Handler pattern after `Get*`:
```go
c, err := store.GetCollection(ctx, id)
if errors.Is(err, ErrNotFound) || (c.UserID != a.UserID && !c.IsPublic) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
```
Same pattern for playlists. The condition collapses unknown-id and not-authorized into one branch so existence-leak vectors don't open up.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| Required field missing (POST: `name`; POST item: `libraryItemId`) | 400 | `<field> required` |
| Unknown collection/playlist on GET (owner or not) | 404 | `collection not found` / `playlist not found` |
| Non-owner GET on private collection/playlist | 404 | same body — no existence leak |
| Non-owner PATCH/DELETE/item-mutation | 404 | same body |
| `library_item_id` not in MediaStore (audiobook add-item) | 404 | `item not found` |
| Store insert/update/delete fails | 500 | sanitized `<collection\|playlist> persist failed`; err logged via `slog.Error` |
| List fetch fails after a successful mutation | 200 + best-effort response (in-memory state) + `slog.Warn` | mutation already committed; falling back beats 500 |
### 7.1 Cross-cutting
- **Anti-enumeration.** All non-owner-or-private paths funnel to the same `<type> not found` 404 (never 403, never "private collection"). Indistinguishable from real not-found.
- **Item validation on add.** `handleAddCollectionBook` and `handleAddPlaylistItem` call `MediaStore.GetAudiobookByID(libraryItemID)` before touching the store. Catches typos and orphan refs early. **Exception:** `handleAddPlaylistItem` with non-empty `episodeId` SKIPS item validation (audiobook-only hydration scope; podcast episodes are stored opaque-id-style).
- **Body size limit.** All POST/PATCH wrap `r.Body` in `io.LimitReader(r.Body, 1<<20)` before `json.NewDecoder`. Same as bookmarks.
- **Batch endpoints.** `POST /playlists/{id}/batch/add` and `/batch/remove` tolerate per-item failures silently (matching continuum's `_ = h.store.Add...`). Total failure (e.g., body decode error) is still a 400. Item validation on the batch path: each item validated individually; failed validations skipped with `slog.Debug` (no 404 — the operation as a whole succeeds with the remaining items).
- **Socket event on batch mutation.** A batch add/remove fires exactly one `playlist_updated` event, regardless of how many items succeeded or failed. Clients re-render from the response.
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
## 8. Testing
### 8.1 Unit tests — `collections_handler_test.go`
In-memory fake `memCollectionStore` (parallel to `memBookmarkStore`). Reuses `stubMediaStore` and `recordingPublisher` from `bookmarks_handler_test.go`. The `dispatchBookmark` helper is generalized in-place (or a sibling `dispatchABS` is added) so the same wiring (URL params + `ctxAuth` injection) drives both new surfaces.
| Test | Asserts |
|---|---|
| `Collection_Create_ReturnsFullShape` | POST `{name:"x"}` → 200, response carries all 7 top-level keys + empty `books[]`, ID is a ULID |
| `Collection_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
| `Collection_List_ReturnsWrappedEnvelope` | GET → 200, body is `{"collections":[…]}`, list-shape omits `books` |
| `Collection_List_DoesNotLeakOtherUsers` | User 1 creates; User 2 GETs → empty list |
| `Collection_Get_Owner_ReturnsFullShape` | POST → owner GET → 200 + `books[]` |
| `Collection_Get_NonOwner_Public_OK` | User 1 creates with `isPublic:true`; User 2 GET → 200 |
| `Collection_Get_NonOwner_Private_404` | User 1 creates (default private); User 2 GET → 404 |
| `Collection_Patch_OwnerUpdatesNameAndDescription` | POST → PATCH `{name:"y", description:"d"}` → 200, fields updated, `lastUpdate` advanced |
| `Collection_Patch_NonOwner_404` | User 2 PATCH on User 1's collection → 404 (no existence leak) |
| `Collection_Delete_OwnerRemovesItAndItems` | POST + add book → DELETE → 204; subsequent GET → 404; items table empty |
| `Collection_Delete_NonOwner_404` | User 2 DELETE → 404, User 1's collection still present |
| `Collection_AddBook_Owner_HydratesInResponse` | POST + add book → 200 + `books[]` contains the entry with `media.metadata.title` |
| `Collection_AddBook_Idempotent` | Add same book twice → 200 both times, `books[]` length stays at 1 |
| `Collection_AddBook_UnknownItem_404` | Add a non-existent item → 404 `item not found` |
| `Collection_RemoveBook_Idempotent` | Remove book not in collection → 200, `books[]` unchanged |
| `Collection_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
| `Collection_Envelope_HasRequiredKeys` | Marshal-test: 7 top-level keys present even when description and books are empty |
### 8.2 Unit tests — `playlists_handler_test.go`
In-memory fake `memPlaylistStore`.
| Test | Asserts |
|---|---|
| `Playlist_Create_ReturnsFullShape` | POST `{name:"x", description:"d", isPublic:true}` → 200, all 8 keys present, ID is ULID |
| `Playlist_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
| `Playlist_Create_FiresPlaylistAddedEvent` | POST → `recordingPublisher` captures `playlist_added` with `{id, name}` |
| `Playlist_List_WrappedEnvelope` | GET → 200, body `{"playlists":[…]}`, list-shape omits `items` |
| `Playlist_AddItem_AppendsAtNextPosition` | POST + add 3 items → response `items[]` positions are 1,2,3 in insertion order |
| `Playlist_AddItem_Idempotent` | Add same `(libraryItemId, episodeId)` twice → list length 1 |
| `Playlist_AddItem_AudiobookHydrates` | Add audiobook item → response item has `title` |
| `Playlist_AddItem_Episode_AcceptsAndEchoes` | Add `{libraryItemId, episodeId:"ep-1"}` → response item has `episodeId`, no `title` (un-hydrated by design) |
| `Playlist_AddItem_UnknownAudiobook_404` | Add non-existent audiobook → 404; episode adds skip validation, so episode-only adds succeed |
| `Playlist_BatchAdd_TolerantOfPartialFailures` | Batch of `[valid, invalid, valid]` → 200, response has 2 items, no 404 |
| `Playlist_BatchAdd_FiresOneUpdatedEvent` | Batch add → single `playlist_updated` event (not one per item) |
| `Playlist_BatchRemove` | Seed 3 items, batch-remove 2 → response items[] length 1 |
| `Playlist_RemoveItem_Single` | DELETE /item/{libraryItemId} → 200, item gone |
| `Playlist_RemoveItem_WithEpisode` | DELETE /item/{libraryItemId}/{episodeId} → 200, episode-keyed item removed; audiobook-keyed item with same libraryItemId still present |
| `Playlist_Patch_UpdatesCover` | PATCH `{cover_item:"id"}` → 200, `coverPath` updated; `playlist_updated` event fires |
| `Playlist_Delete_FiresPlaylistRemovedEvent` | DELETE → 204 + `playlist_removed` event |
| `Playlist_Get_NonOwner_Public_OK` | User 1 creates `isPublic:true`; User 2 GET → 200 |
| `Playlist_Get_NonOwner_Private_404` | Private playlist; non-owner GET → 404 |
| `Playlist_NonOwner_Mutation_404` | User 2 add-item / remove-item / PATCH / DELETE → 404 each, User 1's playlist intact |
| `Playlist_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
| `Playlist_Envelope_HasRequiredKeys` | Marshal-test: 8 top-level keys present; `coverPath` omitted when empty |
### 8.3 Shared envelope tests
One marshal test per surface (`collections_envelope_test.go`, `playlists_envelope_test.go`) asserting the wire shape end-to-end. Parallel to `bookmarks_envelope_test.go`.
### 8.4 Live integration smoke (post-deploy, operator)
```bash
TOKEN=$(curl ... /login | jq -r .accessToken)
ITEM=$(curl ... /api/libraries/9/items?limit=1 | jq -r .results[0].id)
# Collections
COLL=$(curl -X POST -d '{"name":"smoke"}' ... /api/collections | jq -r .id)
curl -X POST ... /api/collections/$COLL/book/$ITEM
curl ... /api/collections/$COLL # books[] contains $ITEM
curl -X DELETE ... /api/collections/$COLL/book/$ITEM
curl -X DELETE ... /api/collections/$COLL # 204
# Playlists
PL=$(curl -X POST -d '{"name":"queue"}' ... /api/playlists | jq -r .id)
curl -X POST -d "{\"libraryItemId\":\"$ITEM\"}" ... /api/playlists/$PL/item
curl ... /api/playlists/$PL # items[] contains $ITEM at position 1
curl -X DELETE ... /api/playlists/$PL/item/$ITEM
curl -X DELETE ... /api/playlists/$PL # 204
```
Expected: each call returns the wire shape from §4; `playlist_added` / `_updated` / `_removed` events appear on the user's Socket.io channel.
### 8.5 Explicitly out of scope
- **No real-Postgres SQL test.** Same posture as bookmarks (§8.4 of that spec): in-memory fakes cover the semantics; CI does not run Postgres for the ABS subdomain.
- **No episode hydration.** Episode IDs accepted and echoed but not resolved to podcast metadata. A future podcast-playlist sub-project will plug in `podcastfeed` hydration.
- **No reorder API.** Future follow-up.
- **No two-user smoke.** Cross-user-public visibility is unit-tested but the operator smoke uses one user.
## 9. Risks & open questions
- **Position gaps after remove.** Removing the middle item from a playlist leaves a `position` gap (1, 3, 4 instead of 1, 2, 3). Clients sort by position; gaps are harmless. If a future reorder API lands, it'll compact positions in a single statement.
- **Cross-user public + profile scoping interaction.** A public collection owned by `(user A, profile primary)` is visible to user B regardless of B's active profile. The collection's `userId` field discloses A's user identity. Acceptable for v1; if A wanted to hide who owns the collection, that's a separate feature.
- **No migration rollback test.** Down migrations are `DROP INDEX + DROP TABLE` — low risk, same posture as bookmarks. Not a blocker.
- **`cover_item` references a `media_items.content_id` but the playlist owner may not have access to that library** — silo's library visibility isn't enforced at the schema level. The cover image hydrator (when added) will need to check visibility. For now, the field round-trips opaquely.
- **Concurrent `AddPlaylistItem` position collision.** Two parallel appends compute `MAX(position)+1` concurrently and may both produce the same value. The UNIQUE constraint on `(playlist_id, library_item_id, episode_id)` prevents collisions for the SAME item, but distinct items could land at the same position. Acceptable — clients tolerate equal positions and break ties by insertion order. A SERIALIZABLE transaction would close the race; not warranted for a low-traffic UX surface.
## 10. Out-of-scope follow-ups
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
- **Smart collections** — sub-project 3 (`abs_smart_collections` + `query_def` JSONB + DSL evaluator).
- **RSS feeds** — sub-project 4 (`abs_rss_feeds`).
- **Listening stats** — sub-project 4 (aggregations on `abs_playback_sessions`).
- **Author / series detail endpoints** — small sub-project, separate.
- **Continue-listening toggles** — small sub-project, separate.
- **Reorder API for playlists** — compaction + drag-reorder UX. Probably warrants its own design pass.
- **Cover-image hydration** — currently the wire emits `coverPath: <content_id>` opaquely. A future enhancement resolves it through `DetailService.PresignURL` to a fully-qualified cover URL.
- **Episode hydration in playlists** — when the podcast-playlist sub-project lands.
- **Collection socket events** — Phase 2 of the parent spec.
## 11. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Sibling sub-project spec: `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md`.
- Wire-shape reference: `continuum-plugin-audiobooks/internal/abs/collections_handler.go` and `playlists_handler.go` (canonical, diff against silo's adaptation before flagging response-shape concerns).
- Client wire usage: `audiobookshelf-app/components/...` Collections and Playlists modal/page components.
@@ -0,0 +1,384 @@
# ABS Implementation Fix — Design
**Status:** Approved (brainstorming complete, awaiting writing-plans handoff)
**Date:** 2026-05-26
**Scope:** Bring silo-server's Audiobookshelf-compatible API to full parity with the canonical `continuum-plugin-audiobooks` implementation so that official ABS iOS, Android, and 3rd-party (Plappa, AudioBookShelfFully) clients work end-to-end against silo.
**Out of scope:** silo's native audiobook surface (`/api/v1/audiobooks/*`), silo-android/silo-apple/silo-plugin-sdk repos, Continuum's plugin-host RPC layer, the "standalone listener" port (silo has no separate process), cover transcoding pipelines, transcribed-audio search.
Commands assume the repository root is the cwd.
---
## 1. Problem
silo's ABS-compat layer at `internal/audiobooks/abs/` exposes ~20% of the surface that the canonical Continuum plugin implements (`continuum-plugin-audiobooks` reference). Real ABS mobile clients cannot complete the basic flow today — login is reported broken, and even when it succeeds many subsequent calls (filterdata, resume position, author/series IDs) return data shapes that break the client. Bookmarks, collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats are entirely absent. Socket.io publishes only 3 of the ~30 events real clients subscribe to.
The user's directive: every feature of ABS clients must function. Source of truth for behavior is the Continuum plugin; the booklore-ng reference docs (`BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`, `src/lib/socket/events.ts`) document specific response-shape bugs and the canonical socket event list.
## 2. Goals & Non-goals
### Goals
- Official ABS iOS app, ABS Android app, and 3rd-party clients (Plappa, AudioBookShelfFully) work end-to-end: add server → login → browse libraries → play → progress sync → bookmark → collection → playlist.
- Token lifecycle is complete: login, refresh, logout, revocation, multi-device tracking.
- Socket.io publishes the full event surface real clients subscribe to.
- New data lives in ABS-scoped tables that don't entangle silo's existing collection system.
### Non-goals
- silo's native audiobook UI changes.
- silo-android / silo-apple client changes.
- Importing real audiobook content via ABS-protocol requests (silo has its own `internal/requests/` system that is not being bridged here).
- Watch-together, podcast download queues, server-side backup event streams.
## 3. Architecture & Strategy
**Source of truth:** `continuum-plugin-audiobooks` (canonical). Port file-by-file, adapt to silo's data model (catalog repos, `auth.Service`, profile model).
**Topology:** silo is monolithic; the ABS layer reads/writes the local DB directly. The Continuum plugin's `HostClient → backend plugin RPC` layer has no analog and is dropped.
**Package layout:** keep silo's existing `internal/audiobooks/{abs,abssocket,podcastfeed}` — it already mirrors the Continuum plugin's structure.
**Listener:** continue using the dedicated `:13378` ABS-compat HTTP server (configured in `internal/config/db_loader.go` as `audiobookshelf_compat.listen`). Routes mounted on a fresh chi router; no SPA fallback collisions.
**Data isolation:** new ABS surface uses dedicated tables (`abs_bookmarks`, `abs_user_collections`, `abs_collection_items`, `abs_playlists`, `abs_playlist_items`, `abs_smart_collections`, `abs_rss_feeds`). silo's existing `library_collections` / `user_personal_collections` / `user_smart_collections` / collection groups are untouched. Future bridging is possible but out of scope here.
## 4. Phasing
Four phases, each independently mergeable and independently verifiable against a real mobile client.
### Phase 0 — Login + critical bug fixes
**Goal:** iOS/Android app can `add server → login → browse library → tap book → play` end-to-end. No bookmarks/collections/etc. yet.
**Concrete changes:**
| File:line | Change |
|---|---|
| `internal/audiobooks/abs/login.go:195-234` | Add to login envelope: `user.itemTagsAccessible: []`, `user.itemTagsSelected: []`, `user.lastSeen: <epoch ms>`, `user.createdAt: <epoch ms>`. Enrich `serverSettings` with `coverAspectRatio`, `dateFormat`, `timeFormat`, `storeCoverWithItem`, `scannerDisableWatcher`, `metadataFileFormat`, `chromecastEnabled`. Mirror Continuum's `completeLogin` (`continuum-plugin-audiobooks/internal/abs/handler.go:591-697`) verbatim. |
| `internal/audiobooks/abs/login.go:274-315` (`handleABSAuthorize`) | Return the identical envelope as `/login`, including `accessToken` and `refreshToken`. Currently omits them, which breaks resume-on-launch. |
| `internal/audiobooks/abs/handler.go:353-391` (bearerAuth) | Audit JTI lookup against `abs_session_store.go:GetTokenByJTI` to confirm the freshly-minted JTI is found. Add `slog.Debug` lines at each rejection branch so the next 401 is traceable. Confirm `?token=` query-param fallback parses correctly (iOS AVPlayer requirement). |
| `internal/audiobooks/abs/libraries_handler.go:499-549` (`siloItemToMetadata`) | Include `id` on every `authors[]` entry (use `item_people.id` UUID). Include `id` on every `series[]` entry (slugified name until a series table lands). Populate `genres` and `tags` from real catalog data instead of empty arrays. Cross-reference `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md` lines 9-100. |
| `internal/audiobooks/abs/play_response.go:119` | Replace hardcoded `currentTime: 0` with `ProgressStore.GetItemProgress(userID, profileID, libraryItemID)` lookup; emit the persisted `currentTime` and `progress` fields. |
| `internal/audiobooks/abs/libraries_handler.go:56-66` | When `?include=filterdata`, hydrate `authors`, `series`, `narrators`, `genres`, `languages`, `tags` aggregations so iOS filter UI populates. |
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /auth/refresh`. Validate refresh token type, check JTI not revoked, mint new access+refresh pair, persist new JTIs. Port from `continuum-plugin-audiobooks/internal/abs/handler.go:handleRefresh`. |
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /logout`. Marks caller's JTIs revoked in `abs_sessions`. Port from Continuum's `handleLogout`. |
| `internal/audiobooks/abs/handler.go:225` (`mountRoutes`) | Mount the new `/auth/refresh` and `/logout` routes (the latter inside `bearerAuth`). |
**Deliverable:** silo build where official ABS iOS app smoke test passes the core flow.
**Size:** ~600-800 lines across ~6 files + 2 new endpoints.
### Phase 1 — Feature surface completion
**Goal:** Bookmarks, manual collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats functional in mobile clients.
**Endpoints to add** (all under `bearerAuth` unless noted; mounted at both `/abs/api/*` and `/api/*`):
**Bookmarks** (port `continuum-plugin-audiobooks/internal/abs/bookmarks_handler.go`)
- `POST /api/me/item/{itemId}/bookmark` — body `{title, time}` → array of all bookmarks for the item. Fires socket `user_updated` (`reason: "bookmark_created"`).
- `PATCH /api/me/item/{itemId}/bookmark` — upsert at time position. Fires `user_updated` (`reason: "bookmark_updated"`).
- `DELETE /api/me/item/{itemId}/bookmark/{time}` — fires `user_updated` (`reason: "bookmark_deleted"`).
**Manual Collections** (port `collections_handler.go`)
- `GET /api/collections` — list with embedded `libraryItems[]`.
- `POST /api/collections` — `{name, description}` → single collection.
- `GET/PATCH/DELETE /api/collections/{id}`.
- `POST /api/collections/{id}/book/{bookId}` — add item.
- `DELETE /api/collections/{id}/book/{bookId}` — remove item.
**Playlists** (port `playlists_handler.go`)
- `GET /api/playlists` → `{playlists: [...]}`.
- `POST /api/playlists` — `{name, description, cover_item, is_public}`.
- `GET/PATCH/DELETE /api/playlists/{id}`.
- `POST /api/playlists/{id}/item` — `{libraryItemId, episodeId?}`.
- `POST /api/playlists/{id}/batch/add` — `{items: [...]}`.
- `POST /api/playlists/{id}/batch/remove`.
- `DELETE /api/playlists/{id}/item/{libraryItemId}[/{episodeId}]`.
**Smart Collections** (port `smart_collection_handler.go` + the entire `internal/smartcoll/` DSL package; adapt SQL to silo's `media_items` schema)
- `GET /api/me/smart-collections` → `{items: [...]}`.
- `POST /api/me/smart-collections` — `{name, description, color, is_public, is_pinned, query_def}`.
- `GET /api/me/smart-collections/{id}`.
- `GET /api/me/smart-collections/{id}/items` — evaluates `query_def` against the catalog.
- `PATCH/DELETE /api/me/smart-collections/{id}`.
**Author detail**
- `GET /api/authors/{id}` — single author with `books[]`.
- `GET /api/authors/{id}/image` — wire to `item_people.poster_path` via `DetailService.PresignURL` (currently 404s).
**Series detail**
- `GET /api/series/{id}` — single series with `books[]` sorted by `series_sequence`.
**RSS Feeds** (port `rss_feed_handler.go` + `internal/podcastfeed/`)
- `POST /api/feeds` — `{itemId, slug, minified}` → `{success, feed: {id, slug, url, ...}}`.
- `GET /api/feeds` — caller's feeds.
- `DELETE /api/feeds/{id}`.
- `GET /abs/public/feed/{slug}` — public RSS XML (no auth, slug is capability token).
- `GET /abs/public/feed/{slug}/cover`.
- `GET /abs/public/feed/{slug}/item/{itemId}/{fileIdx}`.
**Listening stats** (port `internal/abs/handler.go:handleListeningStats` + `handleListeningSessions`)
- `GET /api/me/listening-stats` → `{totalTime, items, days, dayOfWeek, monthly}` from `abs_playback_sessions` aggregation.
- `GET /api/me/listening-sessions?limit=...` — paginated session history.
- `GET /api/me/listening-sessions/{sid}` — single session detail.
**Continue-listening toggles** (port `continue_listening.go`)
- `GET /api/me/progress/{itemId}/remove-from-continue-listening`.
- `GET /api/me/progress/{itemId}/readd-to-continue-listening`.
- Adds `hide_from_continue boolean` column to `user_watch_progress` (the table the ABS layer writes via `abs_progress_store.go`).
**Migrations** (numbered after silo's current max, paired up/down per `CLAUDE.md`):
- `148_abs_bookmarks` — `(id ULID PK, user_id, profile_id, library_item_id, time_seconds, title, created_at, updated_at)` + unique `(user_id, profile_id, library_item_id, time_seconds)`.
- `149_abs_user_collections` + `150_abs_collection_items`.
- `151_abs_playlists` + `152_abs_playlist_items`.
- `153_abs_smart_collections` — includes `query_def jsonb`.
- `154_abs_rss_feeds` — slug is unique capability token.
- `155_abs_progress_hide_from_continue` — adds `hide_from_continue boolean default false`.
**Deliverable:** ABS iOS/Android Library, Collections, Playlists, Bookmarks, RSS, Stats tabs all functional.
**Size:** ~3000-4000 lines across ~15 new files + 7 migration pairs. Splittable into 4 sub-commits (bookmarks, collections+playlists, smart collections, RSS+stats).
### Phase 2 — Socket.io full event parity
**Goal:** A logged-in mobile client sees real-time updates when another device on the same account changes progress, when a server scan adds items, when collections/playlists are mutated remotely, and when an admin posts notifications.
**Event surface** (union of Continuum + booklore-ng — what real ABS iOS source subscribes to):
**Server → Client** (~30 events):
- Lifecycle: `init`, `auth_failed`, `user_online`, `user_offline`, `listener_count`.
- Progress/session: `user_item_progress_updated`, `user_session_open`, `user_session_updated`, `user_session_closed`, `user_stream_update`, `user_stream_end`.
- User-scoped: `user_updated` (bookmark reasons).
- Library: `library_added`, `library_updated`, `library_removed`.
- Items: `item_added`, `item_updated`, `item_removed`, `items_added`.
- People/series: `author_added`, `author_updated`, `author_removed`, `series_added`, `series_updated`, `series_removed`.
- Collections/playlists: `collection_added/updated/removed`, `playlist_added/updated/removed`.
- RSS: `rss_feed_open`, `rss_feed_closed`.
- Tasks: `scan_start`, `scan_progress`, `scan_complete`, `task_started`, `task_progress`, `task_finished`.
- Misc: `notification`.
**Client → Server** (10 events):
- `auth` (exists), `ping`, `join_library`, `leave_library`, `playback_start`, `playback_sync`, `playback_end`, `stream_open`, `stream_close`, `sync_progress`.
**Architecture changes to `internal/audiobooks/abssocket/`:**
- Add room types: existing `user:<userID>`, new `lib:<libraryID>` (joined via `join_library`), new `admin:*`.
- New file `abssocket/publisher.go` exposing `Publisher` interface (`PublishUser`, `PublishLibrary`, `Broadcast`). Replaces ad-hoc `h.publish/h.broadcast` helpers on the Handler struct (currently silent no-ops when `SocketIO == nil`). Tests pass a recording Publisher.
**Cross-package event-source hooks** (via a new `audiobooks.EventBroadcaster` interface in `api.Dependencies` so the consumer packages don't import `abssocket` directly):
- `internal/scanner/` — emit `scan_*`, `item_*`, `items_added` on audiobook-library scan operations.
- `internal/taskmanager/` — emit `task_*` for audiobook-scoped tasks.
- `internal/api/handlers/libraries.go` (or wherever library CRUD lives) — emit `library_*`.
**Auth/lifecycle improvements:**
- Richer `init` payload: `{userId, connectedAt, libraries[], itemTagsAccessible[]}`.
- Heartbeat: respond to `ping` with `pong`.
- On disconnect, decrement listener count and broadcast `user_offline` if this was the user's last device.
- Reconnect replay (last 30s of user-scoped events) is out of scope for v1; leave a hook.
**Migrations:** none — sockets are stateless except for in-memory connection registry.
**Size:** ~1500-2000 lines: ~600 in `abssocket/`, ~400 publisher wiring across audiobook handlers, ~500 cross-package hooks.
### Phase 3 — Hardening
**Media token signing layer** (port `continuum-plugin-audiobooks/internal/mediatoken/`):
- Separate HS256 secret (`audiobooks.abs.media_signing_secret`), 15-min TTL, claims bound to `(user_id, profile_id, book_id, file_idx)`.
- `file_handler.go` mints when building stream URLs; validates on file request.
- Defense-in-depth: a leaked stream URL grants access to ONE file for 15 minutes instead of full account.
- Secret stored in `server_settings`, auto-generated on first read.
**Device tracking** (currently `abs_sessions.device_id = JTI`, useless for UI):
- Parse `User-Agent` on `/login` → derive `device_name`, `client_name`, `client_version`, store on `abs_sessions` insert.
- `GET /api/me/sessions` — list user's sessions (so they see "iPhone 15 Pro — Sep 1").
- `DELETE /api/me/sessions/{jti}` — selective revocation.
**Audit log:**
- New table `abs_audit_log`: `(id, user_id, action, ip, user_agent, metadata jsonb, created_at)`.
- Logged events: login success/failure, logout, token refresh, session revoke, smart-collection-rule changes.
- Optional `GET /api/admin/audit-log` for ops visibility.
**Rate limiting beyond login:**
- Per-user limit on `POST /me/progress` (1/sec — clients sometimes spam this).
- Per-user limit on `POST /auth/refresh` (5/hour — prevent JTI accumulation).
**Size:** ~800-1200 lines + 2 migration pairs.
## 5. Data Model
### New tables (all in Phase 1 unless noted)
```
abs_bookmarks (
id text PRIMARY KEY, -- ULID
user_id integer NOT NULL,
profile_id uuid, -- nullable: primary profile
library_item_id text NOT NULL,
time_seconds double precision NOT NULL,
title text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
UNIQUE (user_id, profile_id, library_item_id, time_seconds)
)
abs_user_collections (
id text PRIMARY KEY, -- ULID
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
is_public boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_collection_items (
collection_id text NOT NULL REFERENCES abs_user_collections(id) ON DELETE CASCADE,
library_item_id text NOT NULL,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (collection_id, library_item_id)
)
abs_playlists (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
cover_item text, -- library_item_id for cover
is_public boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_playlist_items (
playlist_id text NOT NULL REFERENCES abs_playlists(id) ON DELETE CASCADE,
position integer NOT NULL,
library_item_id text NOT NULL,
episode_id text, -- nullable: book item vs podcast episode
added_at timestamptz DEFAULT now(),
PRIMARY KEY (playlist_id, position)
)
abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
color text,
is_public boolean DEFAULT false,
is_pinned boolean DEFAULT false,
query_def jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_rss_feeds (
id text PRIMARY KEY,
user_id integer NOT NULL,
slug text UNIQUE NOT NULL, -- capability token in URL
item_id text NOT NULL,
minified boolean DEFAULT false,
created_at timestamptz DEFAULT now()
)
-- Phase 3
abs_audit_log (
id bigserial PRIMARY KEY,
user_id integer,
action text NOT NULL,
ip inet,
user_agent text,
metadata jsonb,
created_at timestamptz DEFAULT now()
)
```
### Modifications to existing tables
```
-- Phase 1: hide-from-continue toggle
ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean DEFAULT false;
-- Phase 3: real device tracking
ALTER TABLE abs_sessions
ALTER COLUMN device_id DROP DEFAULT,
ADD COLUMN parsed_user_agent text;
```
## 6. Testing Strategy
**Unit tests** (Go, in-package):
- Table-driven per handler in `internal/audiobooks/abs/`: happy path, missing field, wrong field type, IDOR (other user's session/bookmark/collection).
- `abssocket/` `Publisher` recording mock — verify events fire on the right operations with the right payload shape.
- Smart collection DSL evaluator — port Continuum's `smartcoll_test.go`.
**Integration tests** (against real Postgres + Redis via silo's `internal/audiobooks/testutil` harness):
- Full login → browse → play → progress → close-session flow.
- Token refresh — old token rejected, new token accepted.
- Bookmark CRUD round-trip with socket event capture.
- Smart collection rule evaluation against seeded catalog.
**End-to-end (manual)**:
- iOS official ABS app: add server → login → browse → play offline downloads → progress sync across devices.
- Plappa: same flow. Strictest client for response shape; if Plappa works, everything works.
- Android official: same.
**Migration safety:**
- Each migration includes `.down.sql`.
- New helper `scripts/test-abs-migrations.sh` runs up → down → up against a fresh DB.
## 7. Rollout
Single-shot per phase, no human review gate. **Each phase gets its own implementation plan** (separate `writing-plans` cycle) since a combined plan would be unwieldy at this size. Start with Phase 0; subsequent phases planned only after the prior phase merges and verifies.
| Phase | PR | Verify |
|---|---|---|
| Phase 0 | One PR | `make build` → restart silo → curl `/ping` → iOS app smoke test |
| Phase 1 | One PR (or 4 sub-PRs if reviewable size matters) | iOS app: Bookmarks, Collections, Playlists, Stats tabs functional |
| Phase 2 | One PR | Two devices on same account: progress sync visible in real time |
| Phase 3 | One PR | `/api/me/sessions` shows real device names; leaked stream URL expires |
**Cross-repo coordination:** silo-android and silo-apple are NOT touched. The ABS-compat surface targets OFFICIAL ABS clients; silo's own clients use silo's native API.
**Rollback:** every migration has a working `.down.sql`. New tables don't break existing functionality; silo's native audiobook API (`/api/v1/audiobooks/*`) is untouched.
## 8. Open Follow-ups (Explicitly Out of Scope)
- ABS server-side import requests (`/api/me/request`) — Continuum had this; silo has its own `internal/requests/` system. Bridging is a future decision.
- Podcast episode-download queue events (`episode_download_*`) — only relevant if silo gains podcast download capability beyond RSS refresh.
- Backup events — silo's backup story is separate.
- Watch-Together over the ABS socket — silo has `internal/watchtogether/`, deferred.
- Reconnect replay buffer for socket events — Phase 2 leaves a hook but does not implement.
## 9. References
- Source-of-truth implementation: `continuum-plugin-audiobooks` (sibling worktree). Key files: `internal/abs/handler.go`, `bookmarks_handler.go`, `collections_handler.go`, `playlists_handler.go`, `smart_collection_handler.go`, `rss_feed_handler.go`, `continue_listening.go`, `abssocket/server.go`, `mediatoken/`.
- Bug catalog for response shape: `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`.
- Canonical socket event list: `booklore-ng/src/lib/socket/events.ts`.
- ABS API documentation: `booklore-ng/AUDIOBOOKSHELF_API_DOCUMENTATION.md`.
## 10. Phase 0 — Status
**Implemented:** 2026-05-26. Plan: `docs/superpowers/plans/2026-05-26-abs-phase-0-login-and-critical-fixes.md`. All 11 plan tasks committed on `feat/audiobooks` and deployed to the production silo container. Endpoint smoke tests confirm correct mount + status codes for `/ping`, `/login`, `/auth/refresh`, `/logout`, `/me`, `/authorize` at both root and `/api`/`/abs/api` prefixes. End-to-end iOS/Plappa app validation pending operator hand-off.
**Phase 0 commits (13 total — implementation + review-feedback fixes):**
| # | SHA | Subject |
|---|-----|---------|
| 1 | `6c9c721` | feat(audiobooks): diagnostic logging to ABS bearer auth and login |
| 1+ | `d5edb24` | chore(audiobooks): normalize slog "err" key + add path to secret-fetch log |
| 2 | `877120f` | fix(audiobooks): enrich ABS login envelope |
| 2+ | `47c04f1` | chore(audiobooks): reuse existing now var in login envelope |
| 3 | `045984c` | fix(audiobooks): /authorize returns identical envelope to /login |
| 3+ | `0306cfe` | docs(audiobooks): restore x-return-tokens and displayName fallback comments |
| 4 | `e163858` | fix(audiobooks): emit IDs on authors/series and stable genres/tags arrays |
| 4+ | `470a483` | fix(audiobooks): proper pagination total + tags key in play session |
| 5 | `7f600b2` | fix(audiobooks): hydrate filterdata authors and series in library detail |
| 5+ | `2e2b411` | chore(audiobooks): rename const cap to fetchCap in buildFilterData |
| 6 | `7e956f1` | fix(audiobooks): seed currentTime from ProgressStore so resume works |
| 7 | `93e321f` | feat(audiobooks): POST /auth/refresh for ABS token rotation |
| 8 | `278f815` | feat(audiobooks): POST /logout for ABS sign-out |
**Tests added:** 15 (6 metadata + 4 resume + 5 refresh + 4 logout — note 4 logout overlaps with refresh's memTokenStore). `go test ./internal/audiobooks/... -count=1` all pass.
**Next:** Phase 1 (bookmarks, collections, playlists, smart collections, RSS, author/series detail, listening stats) will start with its own brainstorming → writing-plans cycle after operator confirms Phase 0 works against real clients.
@@ -0,0 +1,344 @@
# ABS Phase 1 Close-out — Sub-Project 4
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Final sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`). Closes out the four remaining feature surfaces in one combined spec/plan to ship in a single push.
**Predecessor specs:** bookmarks, collections+playlists, smart collections. Established conventions (anti-enumeration 404, `io.LimitReader(1<<20)`, `errors.Is(err, pgx.ErrNoRows)`, profile-scoped + cross-user-public, 200-on-POST, in-package test fakes, `dispatchABSWithParams`) carry over without re-justification.
Commands assume the repository root is the cwd.
## 1. Goal
Land the four remaining Phase 1 surfaces:
- **Listening stats** — totalTime / days / dayOfWeek / monthly aggregations + paginated session history + per-session detail.
- **Author detail** — `GET /authors/{id}` and `GET /authors/{id}/image` (the latter currently 404s; wire to people poster image).
- **Series detail** — `GET /series/{id}` with embedded books sorted by series_sequence.
- **Continue-listening toggles** — `/me/progress/{itemId}/remove-from-continue-listening` + `/readd-to-continue-listening`, backed by a new `hide_from_continue` column on `user_watch_progress`.
- **RSS feeds** — minimal viable surface: open a feed for a library item, list caller's feeds, close a feed, and serve the public RSS XML to podcast clients. Series/collection feed variants deferred.
## 2. Non-goals
- **No socket events.** Continue-listening toggles, stats, author/series, and RSS feed mutations fire no events (Phase 2 covers full event parity).
- **No series/collection RSS feeds.** Only single-item feeds in v1. The continuum `/api/feeds/series/{id}/open` and `/api/feeds/collection/{id}/open` variants are explicitly deferred.
- **No RSS cover / per-track endpoints.** `GET /feed/{slug}/cover` and `GET /feed/{slug}/item/{itemId}/{idx}` are deferred — podcast clients can fetch covers from the enclosure URL embedded in the RSS XML.
- **No stable series IDs.** silo stores series denormalised on `audiobook_series.content_id` (one row per book); a "series" is identified by `LOWER(series_name)`. The detail handler accepts URL-decoded series names; URL slugs use the lowercased name. Continuum's stable series-row schema is out of scope.
- **No author write surface.** Author detail is read-only.
## 3. Architecture
Five surfaces, sharing the established structure. Three new HTTP handler files; one new migration; minimal extensions to existing stores; one new pgx store for RSS.
- **HTTP layer (`internal/audiobooks/abs/`):**
- `listening_stats_handler.go` — three handlers (stats / list / detail).
- `author_series_handler.go` — three handlers (author / author-image / series).
- `continue_listening_handler.go` — two handlers.
- `rss_feeds_handler.go` — four authenticated handlers + one public XML handler.
- **Store extensions:**
- `ABSPlaybackSessionStore.AggregateStats(ctx, userID, profileID) (Stats, error)` — single SQL with grouped aggregates.
- `ABSPlaybackSessionStore.ListClosedSessions(ctx, userID, profileID, limit, offset) ([]ABSPlaybackSession, int, error)` — paginated history.
- `ProgressStore.SetHideFromContinue(ctx, userID, profileID, contentID string, hide bool) error` — toggle column.
- `MediaStore.GetAuthorByID(ctx, authorID string) (Author, error)` — author detail + books list.
- `MediaStore.GetSeriesByName(ctx, seriesName string) (Series, error)` — series detail + books list ordered by series_index.
- **New store interface + concrete:**
- `RSSFeedStore` interface (CRUD + lookup-by-slug) in `internal/audiobooks/abs/rss_feeds.go`.
- `ABSRSSFeedStore` (pgx) in `internal/audiobooks/abs_rss_feed_store.go`.
- **Migration:**
- `154_user_watch_progress_hide_from_continue.up.sql` — `ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean NOT NULL DEFAULT false`.
- `155_abs_rss_feeds.up.sql` — feed rows (id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at).
- **Service wiring + route registration:** new fields on `abs.Dependencies` + new routes in `mountRoutes` (auth-gated group plus a new unauth public group for `/feed/{slug}.xml`).
## 4. Endpoint surface
All authenticated routes mount under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** except where the `Returns` column says otherwise.
### 4.1 Listening stats
| Verb | Path | Returns |
|---|---|---|
| GET | `/me/listening-stats` | `{totalTime, items, days, dayOfWeek, monthly}` aggregated from `abs_playback_sessions` |
| GET | `/me/listening-sessions` (?limit=, ?page=) | `pagedEnvelope` of session rows (most-recent-first) |
| GET | `/me/listening-sessions/{sid}` | single session detail (404 when not found or not owned) |
Stats wire shape:
```json
{
"totalTime": 36000,
"items": 12,
"days": [{"date": "2026-05-26", "seconds": 1800}, ...],
"dayOfWeek": {"0": 1200, "1": 3600, ..., "6": 900},
"monthly": [{"month": "2026-05", "seconds": 18000}, ...]
}
```
Session row shape:
```json
{
"id": "01HSESS...",
"libraryItemId": "126887...",
"userId": "1",
"startedAt": 1779786284823,
"lastUpdate": 1779786884823,
"duration": 1500,
"currentTime": 1234.5,
"timeListening": 600
}
```
### 4.2 Author + Series detail
| Verb | Path | Returns |
|---|---|---|
| GET | `/authors/{id}` | `{id, name, numBooks, books: [LibraryItem]}` |
| GET | `/authors/{id}/image` | redirect to PresignURL for `item_people.poster_path` when set; otherwise 404 |
| GET | `/series/{id}` | `{id, name, numBooks, books: [LibraryItem]}` ordered by series_index ASC |
`{id}` for authors is the stringified `people.id`. `{id}` for series is the URL-decoded series name (case-insensitive lookup). The author-image route was already mounted unauthenticated in handler.go but currently returns 404 — this spec wires the body.
### 4.3 Continue-listening toggles
| Verb | Path | Returns |
|---|---|---|
| GET | `/me/progress/{itemId}/remove-from-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = true` |
| GET | `/me/progress/{itemId}/readd-to-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = false` |
Both endpoints are idempotent. Item validation via `MediaStore.GetAudiobookByID` (404 on unknown item). The existing `MediaStore.ListContinueListening` SQL is updated to add `AND uwp.hide_from_continue = false` to the WHERE clause.
### 4.4 RSS feeds
| Verb | Path | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/feeds` | bearer | — | `{feeds: [Feed]}` for caller (owner + profile scope, only open feeds) |
| POST | `/api/feeds/item/{itemId}/open` | bearer | `{slug?, minified?}` | created Feed (full shape) |
| POST | `/api/feeds/{id}/close` | bearer | — | 204 (sets closed_at) |
| GET | `/feed/{slug}.xml` | none | — | RSS 2.0 XML; 404 on unknown / closed |
| GET | `/feed/{slug}` | none | — | same as above (some podcast clients omit the `.xml`) |
Feed wire shape:
```json
{
"id": "01HFEED...",
"userId": "1",
"libraryItemId": "126887...",
"slug": "abc-randomsluggenerated",
"minified": false,
"createdAt": 1779786284823,
"url": "https://silo.example.com/feed/abc-randomsluggenerated.xml"
}
```
`slug` is a 16-char URL-safe random string when caller omits it from the body. Manual slugs accepted as long as they match `^[a-z0-9-]{4,64}$`. Slugs are globally unique (UNIQUE index).
RSS XML uses standard RSS 2.0 + iTunes namespace; each `<item>` is one media_file (chapter) of the underlying audiobook. URLs are absolute (`absBaseURL` + `/public/session/...` style — but RSS feeds need a stable public path, so v1 uses a new public file route below). For v1 simplicity, the `<enclosure url>` points at the existing `/abs/public/session/{sid}/track/{idx}` style URL — but feeds don't have an associated session. So we use a new public route mounted in tandem:
**Additional public RSS support route:**
| Verb | Path | Auth | Returns |
|---|---|---|---|
| GET | `/feed/{slug}/file/{ino}` | none | streams the media file for the feed's library_item_id (404 if ino doesn't belong to the item) |
This is a feed-scoped public file route — slug is the capability token, so no Bearer needed. Mirrors the bookmarked-session pattern from sub-project 1.
## 5. Data model
### 5.1 Migration 154 — `hide_from_continue` column
`migrations/154_user_watch_progress_hide_from_continue.up.sql`:
```sql
ALTER TABLE public.user_watch_progress
ADD COLUMN IF NOT EXISTS hide_from_continue boolean NOT NULL DEFAULT false;
```
`migrations/154_user_watch_progress_hide_from_continue.down.sql`:
```sql
ALTER TABLE public.user_watch_progress
DROP COLUMN IF EXISTS hide_from_continue;
```
### 5.2 Migration 155 — `abs_rss_feeds`
`migrations/155_abs_rss_feeds.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_rss_feeds (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
slug text NOT NULL,
minified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS abs_rss_feeds_slug_uniq
ON public.abs_rss_feeds (slug);
CREATE INDEX IF NOT EXISTS abs_rss_feeds_user_profile_idx
ON public.abs_rss_feeds (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/155_abs_rss_feeds.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_rss_feeds_user_profile_idx;
DROP INDEX IF EXISTS public.abs_rss_feeds_slug_uniq;
DROP TABLE IF EXISTS public.abs_rss_feeds;
```
### 5.3 Go models
```go
// Stats aggregates per spec §4.1.
type Stats struct {
TotalTime int // seconds
Items int // distinct content_ids listened to
Days []DayStat // last 30 days
DayOfWeek [7]int // index 0=Sunday
Monthly []MonthStat // last 12 months
}
type DayStat struct{ Date string; Seconds int } // "2026-05-26"
type MonthStat struct{ Month string; Seconds int } // "2026-05"
// Author is the detail-shape row.
type Author struct {
ID string
Name string
PosterPath string // empty when unset; resolved via CoverResolver on emit
Books []*models.MediaItem
}
// Series is the detail-shape row.
type Series struct {
ID string // lowercased series_name
Name string // canonical series_name
Books []*models.MediaItem // ordered by series_index ASC
}
// RSSFeed mirrors an abs_rss_feeds row.
type RSSFeed struct {
ID string
UserID string
ProfileID string
LibraryItemID string
Slug string
Minified bool
CreatedAt time.Time
ClosedAt *time.Time
}
```
## 6. Storage contracts
```go
// Extensions to existing interfaces:
// On ABSPlaybackSessionStore (existing):
AggregateStats(ctx context.Context, userID, profileID string) (Stats, error)
ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error)
// On ProgressStore (existing):
SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error
// On MediaStore (existing):
GetAuthorByID(ctx context.Context, authorID string) (Author, error)
GetSeriesByName(ctx context.Context, seriesName string) (Series, error)
// New store interface — RSSFeedStore (defined in internal/audiobooks/abs/rss_feeds.go):
type RSSFeedStore interface {
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
GetFeed(ctx context.Context, id string) (RSSFeed, error)
CreateFeed(ctx context.Context, f RSSFeed) error
CloseFeed(ctx context.Context, id string) error
}
```
Behavior:
- `AggregateStats` runs one SQL with three CTE subqueries (days / day_of_week / monthly aggregates) plus a `SUM(time_listening_seconds)` and `COUNT(DISTINCT content_id)`.
- `SetHideFromContinue` writes `UPDATE user_watch_progress SET hide_from_continue = $4 WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`. Returns `nil` even when no row matched (idempotent — no-progress means nothing to hide; client can readd later).
- `GetAuthorByID` parses `authorID` as int (`strconv.Atoi`), then `SELECT name FROM people WHERE id = $1` for the author row + `SELECT mi.* FROM item_people ip JOIN media_items mi ON mi.content_id = ip.content_id WHERE ip.person_id = $1 AND ip.kind = 7 AND mi.type = 'audiobook' ORDER BY mi.title`. Returns `ErrNotFound` when the people row is missing.
- `GetSeriesByName` does case-insensitive lookup on `audiobook_series.series_name`: `SELECT DISTINCT series_name FROM audiobook_series WHERE LOWER(series_name) = LOWER($1) LIMIT 1` → name; then `SELECT mi.* FROM audiobook_series s JOIN media_items mi ON mi.content_id = s.content_id WHERE LOWER(s.series_name) = LOWER($1) AND mi.type = 'audiobook' ORDER BY s.series_index NULLS LAST`. Returns `ErrNotFound` when no rows.
- `RSSFeedStore.CreateFeed` inserts with the slug as-is; UNIQUE constraint on slug surfaces as a 409 client-side. `GetFeedBySlug` only returns rows where `closed_at IS NULL` (closed feeds 404 to podcast clients). `CloseFeed` sets `closed_at = now()` — idempotent on already-closed rows.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| 401 missing/invalid bearer | 401 | bearerAuth |
| Body decode (POST `/feeds/.../open`) | 400 | `invalid body` |
| Slug fails `^[a-z0-9-]{4,64}$` | 400 | `invalid slug` |
| Item not found on author/series/feed POST | 404 | `<type> not found` |
| Author/Series unknown ID | 404 | `<author\|series> not found` |
| RSS slug collision on POST | 409 | `slug taken` |
| Non-owner DELETE/close feed | 404 | `feed not found` (anti-enumeration) |
| Public `/feed/{slug}` unknown or closed slug | 404 | `feed not found` (plain text body — podcast clients ignore it) |
| Store mutate fails | 500 | sanitized; err logged |
Cross-cutting:
- Same `io.LimitReader(1<<20)` on POST/PATCH bodies.
- Continue-listening toggles are idempotent and return 200 even on no-row-matched.
- The public RSS routes (`/feed/{slug}.xml`, `/feed/{slug}`, `/feed/{slug}/file/{ino}`) mount OUTSIDE the bearerAuth group — they're capability-gated by the slug. Mount alongside the existing public-session routes in `mountRoutes`.
## 8. Testing
### 8.1 Listening stats
- `TestStats_TotalAggregation` — seed 3 closed sessions, assert `totalTime == sum(time_listening_seconds)` and `items == 2` (3 sessions across 2 distinct items).
- `TestStats_DayOfWeekBucketing` — sessions on a Monday + a Wednesday + another Monday, assert `dayOfWeek[1] == 2 sessions worth, dayOfWeek[3] == 1`.
- `TestSessions_List_PaginatedAndScoped` — paged response shape, other-user sessions excluded.
- `TestSession_Detail_Owner_OK` + `TestSession_Detail_NonOwner_404`.
### 8.2 Author + Series
- `TestAuthor_Detail_ReturnsBooks` — seed author with 2 books; GET returns both, name matches.
- `TestAuthor_Unknown_404`.
- `TestAuthor_Image_PresignWired` — when CoverResolver returns a URL, image route 302s; when poster_path is empty, 404.
- `TestSeries_Detail_OrderedByIndex` — books returned in series_index ASC.
- `TestSeries_Unknown_404` (name doesn't match any audiobook_series row).
### 8.3 Continue-listening
- `TestContinue_Remove_SetsHideTrue` — seed progress row, GET remove-from-continue, assert column becomes true.
- `TestContinue_Readd_SetsHideFalse` — inverse.
- `TestContinue_UnknownItem_404`.
- `TestContinue_NoProgressRow_OK_Idempotent` — no progress row exists; remove-from-continue still returns 200 (idempotent no-op).
### 8.4 RSS
- `TestFeed_Open_Item_GeneratesSlug` — POST `/api/feeds/item/{id}/open` returns a feed with a random 16-char slug.
- `TestFeed_Open_AcceptsCustomSlug`.
- `TestFeed_Open_RejectsInvalidSlug_400`.
- `TestFeed_Open_RejectsCollision_409`.
- `TestFeed_List_OwnerOnly` — only caller's open feeds, profile-scoped.
- `TestFeed_Close_Owner_204`.
- `TestFeed_Close_NonOwner_404`.
- `TestPublicFeed_UnknownSlug_404`.
- `TestPublicFeed_ClosedSlug_404`.
- `TestPublicFeed_HappyPath_RSSXML` — open a feed with a known item; fetch `/feed/{slug}.xml`; assert Content-Type is `application/rss+xml`, body contains `<rss`, `<channel>`, `<item>`, `<enclosure url=`.
### 8.5 Out of scope
- No real-Postgres SQL test.
- No live smoke for the public RSS endpoints (operator can hit them; v1 doesn't include the curl snippet).
- No RSS validator test against a third-party tool.
## 9. References
- Continuum reference: `continuum-plugin-audiobooks/internal/abs/rss_feed_handler.go` (port routing names + slug generation; adapt store/auth to silo).
- silo's existing public-route precedent: `handlePublicTrack` in `internal/audiobooks/abs/file_handler.go` — same slug-as-capability pattern.
- Item-people kinds: 7 = author per `media_store.go:ListLibraryAuthors`. Narrator kind not used here.
## 10. Out-of-scope follow-ups
- RSS feed variants: series feeds, collection feeds, cover endpoint, per-track public route is included but is the minimum.
- Listening-stats `/all-time` vs `/last-N-days` filters — current spec returns a single aggregate.
- Author edit / merge surface.
- Series metadata edit.
- Stats charts beyond the three buckets (per-genre, per-author, per-narrator) — defer.
@@ -0,0 +1,377 @@
# ABS Smart Collections — Phase 1 Sub-Project 3
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Third sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor specs:** `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`. Conventions established there (anti-enumeration 404, `io.LimitReader(1<<20)` body cap, `errors.Is(err, pgx.ErrNoRows)` store pattern, profile-scoped + cross-user-public read, 200 OK on POST, in-package test fakes + `dispatchABSWithParams`) are reused without re-justification.
Commands in this document assume the repository root is the cwd.
## 1. Goal
Land rule-based "smart" audiobook collections on the silo ABS surface so the official Audiobookshelf clients can define dynamic groupings ("books from 2020-2024 by Brandon Sanderson", "audiobooks I've started but haven't finished", "5-star fantasy under 12 hours"). Rules are stored as a JSON DSL; the items endpoint evaluates the rules against the catalog at request time and returns paginated `LibraryItem` results.
## 2. Non-goals
- **No SQL pushdown.** Evaluation walks the candidate list in Go. silo's audiobook library is small enough (hundreds of items) that linear scan per request is fine. SQL pushdown is a Phase 4 follow-up if the library grows past ~5000 items.
- **No rule-builder UI.** Clients ship their own; silo just stores and evaluates `query_def`.
- **No podcast smart collections.** The DSL catalog is audiobook-domain only (title/author/narrator/series/genre/...). Podcast smart collections, if ever needed, get their own DSL.
- **No socket events.** Matches the manual-collections decision in sub-project 2. Phase 2 covers full event parity.
- **No saved-search aliases.** A smart collection is fully described by its `query_def`; there's no separate "saved query" entity.
- **No background materialisation.** Each `/items` call re-evaluates. If perf matters later, add a 30s in-memory result cache keyed on `(collectionID, userID)`.
## 3. Architecture
Three layers, sharing the established silo ABS structure:
- **DSL layer (new package `internal/audiobooks/smartcoll/`):**
- `query.go` — `QueryDefinition`, `QueryGroup`, `QueryRule`, `QuerySort` types + field/sort catalogs + `Normalize`/`Validate`/`MarshalJSON`. Ported from continuum's `internal/smartcoll/query.go` verbatim except for the import path.
- `evaluator.go` — `Candidate`, `EvaluateOptions`, `Evaluate(ctx, qd, candidates, opts) []Candidate`. Pure function, no I/O. Ported from continuum.
- `query_test.go` + `evaluator_test.go` — port continuum's test suites; remove any references to continuum-specific backend types.
- **HTTP layer (`internal/audiobooks/abs/`):**
- `smart_collections_handler.go` — six handlers: `handleListSmartCollections`, `handleCreateSmartCollection`, `handleGetSmartCollection`, `handleSmartCollectionItems`, `handleUpdateSmartCollection`, `handleDeleteSmartCollection`.
- `smart_collections_handler_test.go` — in-memory `memSmartCollectionStore` + handler tests reusing `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher` from prior sub-projects' test files.
- `smart_collections_envelope_test.go` — wire-shape marshal test.
- **Storage layer:**
- `internal/audiobooks/abs/smart_collections.go` — `SmartCollectionStore` interface + `SmartCollection` Go model + `smartCollectionToABS` serialiser.
- `internal/audiobooks/abs_smart_collection_store.go` — pgx-backed `ABSSmartCollectionStore` (parallel to `abs_collection_store.go`).
- **Migration:** `153_abs_smart_collections.up.sql` + `.down.sql`.
- **Bookmark store extension:**
- `BookmarkStore.CountByUser(ctx, userID, profileID) (map[string]int, error)` returning `{libraryItemID: count}` for batch hydration of the personalized `bookmark_count` rule. Single SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2, sentinel) GROUP BY library_item_id`.
- **Service wiring:** `BuildABSHandler` constructs the new store; field lands on `abs.Dependencies` next to `PlaylistStore`.
## 4. Endpoint surface
All routes mounted under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** unless the `Returns` column says otherwise.
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/me/smart-collections` | — | `{"items": [SmartCollection list-shape]}` |
| POST | `/me/smart-collections` | `{name, description?, color?, isPublic?, isPinned?, query_def}` | SmartCollection full-shape |
| GET | `/me/smart-collections/{id}` | — | SmartCollection full-shape when owner OR `isPublic=true`; otherwise 404 |
| GET | `/me/smart-collections/{id}/items` | (query: `?limit=`, `?page=`) | Paged envelope of `LibraryItem` results from rule evaluation |
| PATCH | `/me/smart-collections/{id}` | `{name?, description?, color?, isPublic?, isPinned?, query_def?}` | SmartCollection full-shape |
| DELETE | `/me/smart-collections/{id}` | — | 204 No Content |
### 4.1 Wire shape — SmartCollection
**List-shape and full-shape are identical** (unlike manual collections / playlists where the list-shape omits child items). Smart collections have no stored items; the items are computed by the separate `/items` route. So one shape:
```json
{
"id": "01HSC...",
"userId": "1",
"name": "Recent Fantasy",
"description": "fantasy added in the last 6 months",
"color": "#3b82f6",
"isPublic": false,
"isPinned": true,
"queryDef": {
"library_ids": [9],
"match": "all",
"groups": [{
"match": "all",
"rules": [
{"field": "genre", "op": "contains", "value": "Fantasy"},
{"field": "added_at", "op": "in_last", "value": "180d"}
]
}],
"sort": {"field": "added_at", "order": "desc"},
"limit": null
},
"createdAt": 1779786284823,
"updatedAt": 1779786284823
}
```
All ten top-level keys always present (`description`, `color` default to `""`; `isPublic`, `isPinned` default to `false`; `queryDef` is the parsed JSON, never raw bytes on the wire). Timestamps are JS-epoch milliseconds.
### 4.2 Wire shape — `/items` envelope
Standard ABS paged envelope (`pagedEnvelope` helper already in `handler.go`):
```json
{
"results": [ /* hydrated LibraryItem objects */ ],
"total": 42,
"limit": 30,
"page": 0,
"sortBy": "added_at",
"sortDesc": true,
"filterBy": "",
"minified": false,
"include": ""
}
```
`results` items use the same `siloItemToLibraryItem` shape as `/api/libraries/{id}/items`. Pagination is post-eval slice (eval order is preserved; pages are stable for a given `(collection, user)` as long as the underlying catalog doesn't change).
### 4.3 List-envelope wrap key
Smart-collection LIST uses `{"items": [...]}` — matches continuum's wrap key (which differs from manual collections' `{"collections": [...]}`). Clients pattern-match on the wrap key when distinguishing surfaces; don't override.
## 5. DSL surface (full vocabulary)
### 5.1 Fields (15 total)
**Non-personalized (10):**
| Field | Type | Valid ops | Source |
|---|---|---|---|
| `title` | scalar string | `is`, `is_not`, `contains` | `media_items.title` |
| `author` | array of strings | `is`, `is_not`, `contains` | item people: role=author |
| `narrator` | array of strings | `is`, `is_not`, `contains` | item people: role=narrator |
| `series` | array of strings | `is`, `is_not`, `contains` | `audiobook_series` |
| `genre` | array of strings | `is`, `is_not`, `contains` | `media_items.genres` |
| `year` | int | `is`, `is_not`, `gt`, `gte`, `lt`, `lte`, `between` | `media_items.release_year` |
| `rating` | float | `gt`, `gte`, `lt`, `lte`, `between` | `media_items.rating_imdb` (or whichever rating silo populates) |
| `language` | scalar string | `is`, `is_not` | `media_items.original_language` |
| `publisher` | scalar string | `is`, `is_not`, `contains` | `media_items.publisher` |
| `added_at` | timestamp | `gt`, `lt`, `between`, `in_last` | `media_items.added_at` |
| `duration_seconds` | int | `gt`, `gte`, `lt`, `lte`, `between` | sum of media_files.duration for the item |
**Personalized (5)** — require `AllowPersonalized: true` (only when caller is the collection's owner):
| Field | Valid ops | Source |
|---|---|---|
| `finished` | `is` | `user_watch_progress.is_finished` |
| `in_progress` | `is` | progress exists AND `is_finished == false` AND `current_seconds > 0` |
| `last_played` | `gt`, `gte`, `lt`, `lte`, `between`, `in_last` | `user_watch_progress.updated_at` |
| `abandoned` | `is` | `in_progress == true` AND `last_played < now() - 60d` (configurable via `EvaluateOptions.AbandonedAfter`) |
| `bookmark_count` | `gt`, `gte`, `lt`, `lte`, `between` | count of `abs_bookmarks` rows for (user, profile, item) |
### 5.2 Sort fields
`title` (asc), `added_at` (desc), `year` (desc), `duration_seconds` (desc), `rating` (desc), `random` (deterministic shuffle seeded by `userID + ":" + collectionID`), and personalized: `progress` (desc), `last_played` (desc), `plays` (desc).
### 5.3 Operators
- `is` / `is_not` — equality (case-insensitive for strings; numeric otherwise). For array fields, `is` matches when ANY element equals the value.
- `contains` — substring (case-insensitive) for scalar strings; element-substring for array fields.
- `gt` / `gte` / `lt` / `lte` — numeric or timestamp comparison.
- `between` — `value` is a 2-element array `[low, high]`, inclusive bounds.
- `in_last` — relative window. `value` is a duration string (`"7d"`, `"180d"`, `"24h"`, `"4w"`). Evaluator parses to `time.Duration`; field must be ≥ `opts.Now - duration`.
### 5.4 Aliases
`authors` → `author`, `narrators` → `narrator`, `genres` → `genre` for fields. `sort_title` → `title`, `recently_added` → `added_at`, `duration` → `duration_seconds` for sorts. Applied in `Normalize()`; older clients with plural / verbose names keep working.
## 6. Data model
### 6.1 Migration 153
`migrations/153_abs_smart_collections.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
is_pinned boolean NOT NULL DEFAULT false,
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
ON public.abs_smart_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/153_abs_smart_collections.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_smart_collections;
```
### 6.2 Schema rationale
- **`query_def jsonb`** — JSONB so future GIN-indexed JSON path probes are an option; default `'{}'::jsonb` so rows with no rules are queryable without NULL guards.
- **No items table** — smart collections have no stored children; items are computed at eval time.
- **`color`, `is_pinned`** — UI decorations. silo doesn't validate the color format.
- **No `library_ids` column** — that list is part of the DSL, lives inside `query_def`.
### 6.3 Go model
```go
type SmartCollection struct {
ID string
UserID string
ProfileID string
Name string
Description string
Color string
IsPublic bool
IsPinned bool
QueryDef []byte // raw JSONB bytes; decoded on the items path
CreatedAt time.Time
UpdatedAt time.Time
}
```
## 7. Storage contract
```go
type SmartCollectionStore interface {
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
CreateSmartCollection(ctx context.Context, c SmartCollection) error
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
DeleteSmartCollection(ctx context.Context, id string) error
}
```
Behavior follows the established silo pattern: ordered by `created_at DESC`, empty slice never nil, `GetSmartCollection` returns `ErrNotFound` without owner check (handler authorizes), `Update` sets `updated_at = now()`, `Delete` returns nil even on no-match.
**Extension to existing `BookmarkStore`:**
```go
// CountByUser returns a map of library_item_id → bookmark count for
// the given (user, profile). Empty map (never nil) when none.
// Used by the smart-collection items evaluator to hydrate the
// personalized `bookmark_count` rule in one SQL pass.
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
```
SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2::uuid, sentinel) GROUP BY library_item_id`.
## 8. Items evaluation flow
`handleSmartCollectionItems` runs:
1. Auth + store-nil + URL `{id}` checks (same boilerplate as other handlers).
2. `GetSmartCollection(ctx, id)` → 404 on ErrNotFound OR `(non-owner AND !isPublic)`.
3. Decode `c.QueryDef` → `smartcoll.QueryDefinition`. 500 on malformed (shouldn't happen — `Validate` is called on persist; defensive guard).
4. Resolve target libraries: `qd.LibraryIDs` if non-empty, else every audiobook library from `MediaStore.ListAudiobookLibraries`.
5. For each target library, fetch the audiobook list via `MediaStore.ListAudiobooks(ctx, libID, 5000, 0)`. (5000 cap matches continuum's; silo's libraries are smaller.)
6. **Build Candidates.** For each item:
- `Candidate.Item` = the `*models.MediaItem`.
- If `c.UserID == a.UserID` (owner): hydrate per-user state.
- Fetch all progress rows: `ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 10000)`. Build `map[contentID]ProgressRow`.
- Fetch bookmark counts: `BookmarkStore.CountByUser(ctx, a.UserID, a.ProfileID)`. Map `contentID → count`.
- Populate `IsFinished`, `ProgressPct`, `CurrentSeconds`, `LastPlayedAt`, `BookmarkCount`.
- Non-owner viewing public collection: skip hydration; personalized rules eval against zero-values.
7. `smartcoll.Evaluate(ctx, qd, candidates, EvaluateOptions{AllowPersonalized: c.UserID == a.UserID, UserSeed: a.UserID + ":" + c.ID, Now: time.Now(), AbandonedAfter: 60*24*time.Hour})` → matched + sorted.
8. Paginate: `limit, page := readPagedQuery(r, 30)`. Override default limit with `qd.Limit` when `?limit=` query param is absent. Slice `matched[page*limit : (page+1)*limit]`.
9. Hydrate each result via the existing `siloItemToLibraryItem` helper (same as `/api/libraries/{id}/items`).
10. Wrap in `pagedEnvelope`. Write 200.
### 8.1 Performance budget
- Library fetch: one SQL query per library (~hundreds of rows).
- Progress hydration: one SQL query for the user's full progress list.
- Bookmark hydration: one SQL query for the aggregated counts.
- Total: 2 + N (libraries) queries, all paginated/limited at the store layer. silo's current 1-library config means 3 total queries per `/items` request. Fast enough.
## 9. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| `name` missing on POST | 400 | `name required` |
| `query_def` fails `Validate(allowPersonalized=true)` | 400 | `invalid query_def: <reason>` (reason from validator — safe to surface) |
| Unknown collection on GET (owner or not) | 404 | `smart collection not found` |
| Non-owner GET on private | 404 | same body — no leak |
| Non-owner PATCH/DELETE/items | 404 | same body |
| Store mutate fails | 500 | `smart collection persist failed` (delete: `smart collection delete failed`); err logged via `slog.Error` |
| `/items` eval — `MediaStore.ListAudiobooks` fails for a library | log `slog.Warn` and skip that library | partial results beat 500 |
| Personalized rule on non-owner-public eval | silently dropped at `smartcoll.Evaluate` via `AllowPersonalized: false` | no error to the client |
**Cross-cutting:** Body size limit `io.LimitReader(r.Body, 1<<20)` on all POST/PATCH. `validate(allowPersonalized=true)` runs on every persist call; on read, the items handler passes `AllowPersonalized = (c.UserID == a.UserID)` so saved personalized rules are non-fatally dropped when viewed by non-owners.
## 10. Testing
### 10.1 DSL unit tests (`internal/audiobooks/smartcoll/`)
Port continuum's `query_test.go` and `evaluator_test.go` verbatim. The continuum reference covers:
- `Normalize`: aliasing, lowercase, dedupe library_ids, default-match `"all"`, default-sort `"added_at"`.
- `Validate`: unknown field, invalid op for field, personalized rule without scope, invalid sort field, invalid sort order, negative limit.
- `Evaluate`: each operator for each field type, AND/OR combinator for groups and definition, personalized rules drop when `AllowPersonalized: false`, `random` sort stability per `UserSeed`, `Limit` honored, empty rules match-everything.
### 10.2 Handler tests (`smart_collections_handler_test.go`)
In-memory `memSmartCollectionStore` (parallel to `memCollectionStore`); reuses `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher`. The bookmark store extension (`CountByUser`) needs a matching method on `memBookmarkStore` from sub-project 1's test file — extend that fake.
| Test | Asserts |
|---|---|
| `SmartCollection_Create_ReturnsFullShape` | POST → 200, 10 top-level keys present, ULID, queryDef round-tripped as a nested object (not raw bytes) |
| `SmartCollection_Create_NameRequired_400` | empty name → 400 |
| `SmartCollection_Create_InvalidBody_400` | malformed JSON → 400 |
| `SmartCollection_Create_InvalidQueryDef_400` | `{"groups":[{"rules":[{"field":"nonsense","op":"is","value":1}]}]}` → 400 with reason in body |
| `SmartCollection_Create_PersonalizedRuleAllowed` | rule with `field:"finished"` accepted (persist always runs with `allowPersonalized=true`) |
| `SmartCollection_List_WrappedAsItems` | GET → `{"items": [...]}` envelope key (NOT `"collections"`) |
| `SmartCollection_List_DoesNotLeakOtherUsers` | user 1 creates; user 2 lists → empty |
| `SmartCollection_List_ProfileIsolation` | profile A creates; profile B lists same user → empty |
| `SmartCollection_Get_Owner_ReturnsFullShape` | owner GET → 200 + full-shape |
| `SmartCollection_Get_NonOwner_Public_OK` | public GET by other user → 200 |
| `SmartCollection_Get_NonOwner_Private_404` | private GET by other user → 404 (anti-enumeration) |
| `SmartCollection_Get_Unknown_404` | unknown ID → 404 |
| `SmartCollection_Patch_Owner_UpdatesFields` | partial PATCH updates only present fields |
| `SmartCollection_Patch_NonOwner_404` | non-owner PATCH → 404, original untouched |
| `SmartCollection_Patch_InvalidQueryDef_400` | PATCH with bad query_def → 400 |
| `SmartCollection_Delete_Owner_204` | DELETE → 204; subsequent GET → 404 |
| `SmartCollection_Delete_NonOwner_404` | non-owner DELETE → 404, original still present |
| `SmartCollection_Items_Owner_EvaluatesRules` | seed 3 audiobooks (two match the rule, one doesn't); GET /items returns 2 results |
| `SmartCollection_Items_PersonalizedDroppedForNonOwner` | public collection with `field:"finished"` rule — non-owner sees ALL matching books (personalized rule silently dropped); owner sees only finished |
| `SmartCollection_Items_PaginatedEnvelope` | GET /items → standard pagedEnvelope with 9 fields |
| `SmartCollection_Items_RespectsQueryDefLimit` | qd.Limit=5; GET without ?limit returns 5; GET with ?limit=10 returns 10 (request overrides) |
| `SmartCollection_Items_NonOwner_Private_404` | private collection /items by non-owner → 404 |
| `SmartCollection_Envelope_HasRequiredKeys` | marshal-test for 10 top-level keys with empty defaults |
### 10.3 Live integration smoke (operator)
```bash
TOKEN=$(curl ... /login | jq -r .accessToken)
# Create a smart collection: "fantasy added in the last 6 months"
SC=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"recent fantasy","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"contains","value":"Fantasy"},{"field":"added_at","op":"in_last","value":"180d"}]}],"sort":{"field":"added_at","order":"desc"}}}' \
http://127.0.0.1:13378/api/me/smart-collections | jq -r .id)
# Evaluate
curl ... /api/me/smart-collections/$SC/items | jq '.results | length'
# Patch name
curl -X PATCH ... -d '{"name":"renamed"}' /api/me/smart-collections/$SC | jq .name
# Delete
curl -X DELETE ... /api/me/smart-collections/$SC # 204
```
### 10.4 Out of scope
- No real-Postgres SQL test (same posture as prior sub-projects).
- No GIN-index on `query_def`. Phase 4 follow-up if rule-querying becomes a feature.
- No performance test. Library is small; linear scan is fine.
## 11. Risks & open questions
- **5000-item per-library cap.** If a library exceeds this, the over-fetch silently truncates. silo's libraries are well below this today; if it changes, switch to paged ListAudiobooks + accumulate.
- **Personalized rule eval against non-owners is silent.** A non-owner viewing a public collection sees results that don't honor the owner's personalized rules. This is the privacy-correct behavior, but a confused user might wonder why "books I haven't finished" shows everything when viewed by their friend. Document in the parent spec's UX notes.
- **`random` sort + pagination.** Two requests with the same `(user, collection)` produce the same shuffle (seeded). Browser-back / page-2 stays consistent. If the catalog mutates between requests, the seed still works but new items land in unpredictable positions — acceptable.
- **`query_def` migration on field-catalog changes.** If silo renames a field or removes an op in the future, existing rows persist the old vocab. `Validate` would reject them, but Normalize won't drop them — we'd surface stale rules as a runtime warning in the items handler. Acceptable for v1.
- **`description` length.** No cap; relies on the 1 MiB body limit. Same posture as other surfaces.
## 12. Out-of-scope follow-ups
- SQL-pushdown evaluator for large libraries.
- GIN-indexed JSON path queries on `query_def`.
- Background materialisation cache for hot smart collections.
- Cross-collection composition ("collection A + collection B").
- Result count badges on `/me/smart-collections` (would require running eval on every list call — expensive).
- Audiobook recommender hooks (the `relevance` sort placeholder in continuum).
## 13. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Sibling sub-project specs: `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`.
- DSL reference: `continuum-plugin-audiobooks/internal/smartcoll/{query,evaluator}.go` + matching tests (port verbatim).
- HTTP reference: `continuum-plugin-audiobooks/internal/abs/smart_collection_handler.go`.
@@ -0,0 +1,295 @@
# Unified User Collections (Audiobooks + Movies + TV) Design
**Status:** design — pending implementation plan.
**Source brainstorm:** in-session, 2026-05-27. Driven by audiobook coverage gaps in `page_sections`, `library_collections`, and `user_personal_collections`, plus a parallel `abs_*` collection/playlist/smart-collection stack that doesn't intersect with the silo-canonical tables.
**Commands assume the repository root is the cwd.**
---
## 1. Problem
Audiobooks today live in a parallel collections world from movies and TV:
| Surface | Audiobook coverage today |
|---|---|
| `page_sections` (homepage rails) | None. Recipes reference only `movie`/`series`. |
| `library_collections` (admin-owned, library-scoped) | Schema supports `MediaAudiobook` kind but nothing surfaces audiobook-typed admin collections in practice. |
| `user_personal_collections` (user-owned) | Zero references to audiobooks anywhere in `internal/usercollections/`. |
| `abs_user_collections` + `abs_collection_items` | Full audiobook-only user-curated collections behind the ABS-compat API. |
| `abs_smart_collections` | Audiobook-only smart collections with a rule DSL (port of continuum-plugin-audiobooks). |
| `abs_playlists` + `abs_playlist_items` | Audiobook + podcast-episode ordered playlists. |
Net effect: audiobooks don't appear in any of silo's first-party discovery surfaces, the ABS-app's collection/playlist features don't intersect with silo's native ones, and we maintain two parallel storage stacks.
The data inside the `abs_*` tables today is essentially empty (1 playlist, 0 user collections, 0 smart collections), so unification can land via hard cutover with minimal data migration cost.
---
## 2. Goals
- One canonical storage for user-owned lists across all media types.
- Audiobook items eligible for `page_sections` recipes, `library_collections`, and `user_personal_collections`.
- ABS-compat endpoints (`/api/collections`, `/api/playlists`, `/api/smart-collections`) continue to work and return identical wire shapes, but their persistence collapses into the canonical store.
- One smart-collection engine evaluated against all media types.
- Podcast-episode-level granularity preserved (ABS playlists can name a specific episode within a library item).
## 3. Non-goals
- Admin-curated audiobook collections via the ABS API surface. `library_collections` already supports audiobooks; whether ABS clients should see those admin lists is a separate question deferred for a later design.
- Frontend changes. Audiobook detail/library pages already exist (per `2026-05-24-audiobook-ui-redesign`) and will consume the unified endpoints when they ship. An admin section-builder media-type selector and any UI surfacing of audiobook sections/collections are follow-up plans.
- Cross-user shared collections, public sharing, social features. Out of scope.
- Episode-level granularity for movies/TV. The new `sub_item_id` column is nullable and only populated by podcast playlists.
## 4. Architecture
### 4.1 Storage model
One canonical user-owned-lists table — `user_personal_collections` — extended to discriminate four flavors via `collection_type`:
| `collection_type` | Semantics |
|---|---|
| `manual` | Existing. Hand-curated list of items. Order optional. |
| `synced` | Existing. External sync (Trakt etc.). Items derived from external source. |
| `playlist` | **New.** Ordered sequence; `position` column on items is meaningful. |
| `smart` | **New.** Rule-based; items materialized at read time from `query_definition` via `internal/smartcoll/`. |
`user_personal_collection_items` gains one nullable column:
```sql
ALTER TABLE user_personal_collection_items ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
```
`sub_item_id` is populated only when the entry refers to a sub-item of a library item (today: podcast episode within a podcast library item). All other entries leave it empty. Audiobook entries always use empty `sub_item_id` because an audiobook is a single `media_items` row.
The CHECK constraint on `collection_type` is widened to admit the new values:
```sql
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
```
Admin-curated lists (`library_collections`, `library_collection_items`, etc.) are unchanged. They already support audiobooks through the existing `MediaKind` enum.
### 4.2 Smart-collection engine
The current audiobook-only smart-collection engine in `internal/audiobooks/smartcoll/` is promoted to `internal/smartcoll/`:
```
internal/audiobooks/smartcoll/ → internal/smartcoll/
```
Rationale: smart collections are no longer audiobook-only. The DSL operates on `media_items` regardless of type; audiobook-specific rule kinds (e.g. `narrator`, `series_position`) stay registered but evaluate to no-op predicates on non-audiobook items.
The existing simple-shape `query_definition` on `user_personal_collections` (mostly `library_ids` filter) is a strict subset of the smartcoll DSL — existing rows continue to evaluate correctly because their library-id filter maps to the new DSL's library-id predicate.
### 4.3 ABS-compat handler mapping
The three ABS store adapter files in `internal/audiobooks/` get their bodies rewritten to query the canonical tables. Method signatures stay the same so the ABS HTTP handlers above them don't change.
| ABS endpoint | Backing query after cutover |
|---|---|
| `GET /api/libraries/{id}/collections` | `SELECT … FROM user_personal_collections WHERE collection_type='manual'` joined to items in this library |
| `POST /api/collections` | INSERT `user_personal_collections` with `collection_type='manual'`, scoping via `query_definition.library_ids` |
| `GET /api/libraries/{id}/playlists` | `SELECT … FROM user_personal_collections WHERE collection_type='playlist'` joined to items in this library |
| `POST /api/playlists` | INSERT `user_personal_collections` with `collection_type='playlist'` |
| `GET /api/libraries/{id}/smart-collections` | `SELECT … FROM user_personal_collections WHERE collection_type='smart'`; items materialized via `internal/smartcoll` |
| `episode_id` in playlist items wire shape | maps to `sub_item_id` column |
### 4.4 Page sections (homepage rails)
`page_sections` rows gain a media-types filter. The cleanest carrier is a typed column:
```sql
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
Default preserves the current "movies and TV" behavior on existing rows so the migration doesn't change any user-visible rails.
Each recipe declaration in `internal/sections/recipes/` gains a `SupportedMediaTypes` field. Fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes set `SupportedMediaTypes = ['movie','series']`; they keep their current behavior. Recipes that work for audiobooks declare `['movie','series','audiobook']` (or `['audiobook']` for audiobook-only ones).
Two new recipes ship at the same time:
- **`continue_listening`** — analog of `continue_watching`; audiobook items with non-zero progress and not finished.
- **`by_audiobook_series`** — book series rail, drawing from the `audiobook_series` table; mirrors `by_show`.
More audiobook-flavored recipes (top narrators, by genre, new from followed authors) are deferred until the baseline two are exercised.
### 4.5 ABS app contract preservation
Wire-shape compatibility is part of the contract — the ABS Android/iOS apps cannot break. The store-adapter rewrites must produce byte-for-byte identical JSON for the existing endpoints. Concretely:
- IDs remain stable for the moved row(s). The migration preserves `abs_playlists.id` as the new `user_personal_collections.id`.
- `episode_id` field is emitted from `sub_item_id` (empty string when null/empty).
- `is_public` field on playlists/smart collections is emitted from `is_shared`.
- `cover_item` on playlists — needs decision (see §6).
### 4.6 Module structure after the change
```
internal/
smartcoll/ -- NEW (promoted from audiobooks/smartcoll/)
types.go
evaluator.go
rule_registry.go
usercollections/ -- existing; extended for playlist/smart kinds
listing.go -- gains Kind filter
types.go -- adds CollectionKind = playlist|smart
smartfetch.go -- new; materializes smart-collection items via internal/smartcoll
sections/
recipes/
*.go -- each gains SupportedMediaTypes
continue_listening.go -- new
by_audiobook_series.go -- new
audiobooks/
abs_collection_store.go -- rewritten: canonical SQL
abs_playlist_store.go -- rewritten: canonical SQL
abs_smart_collection_store.go -- rewritten: canonical SQL + smartcoll
smartcoll/ -- REMOVED (lifted to internal/smartcoll/)
```
---
## 5. Migration
Single pair: `migrations/156_unify_user_collections.up.sql` / `.down.sql`. Hard cutover.
### 5.1 Up
```sql
-- 1. Extend canonical items table with sub-item granularity.
ALTER TABLE user_personal_collection_items
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
-- 2. Widen the collection_type enum.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
-- 3. Move the single existing abs_playlists row.
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
SELECT
id, user_id, COALESCE(profile_id::text, ''), name, description, 'playlist',
is_public, created_at, updated_at, COALESCE(profile_id::text, '')
FROM abs_playlists;
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT p.user_id, i.playlist_id, i.library_item_id, i.episode_id, i.position, i.added_at
FROM abs_playlist_items i
JOIN abs_playlists p ON p.id = i.playlist_id;
-- 4. Drop the abs_* collection tables.
DROP TABLE abs_playlist_items;
DROP TABLE abs_playlists;
DROP TABLE abs_collection_items;
DROP TABLE abs_user_collections;
DROP TABLE abs_smart_collections;
-- 5. Extend page_sections with media-type filter.
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
### 5.2 Down
```sql
-- Reverse the page_sections column.
ALTER TABLE page_sections DROP COLUMN media_types;
-- Recreate the abs_* tables empty by re-applying their CREATE TABLE
-- statements verbatim from the original migration files (no data restored):
-- migrations/149_abs_user_collections.up.sql → abs_user_collections
-- migrations/150_abs_collection_items.up.sql → abs_collection_items
-- migrations/151_abs_playlists.up.sql → abs_playlists
-- migrations/152_abs_playlist_items.up.sql → abs_playlist_items
-- migrations/153_abs_smart_collections.up.sql → abs_smart_collections
-- The implementer copies the CREATE TABLE bodies (and indexes) into this
-- down migration; constraint/index names must match the originals so a
-- subsequent up-down-up cycle is idempotent.
-- Remove rows we promoted.
DELETE FROM user_personal_collection_items
WHERE collection_id IN (
SELECT id FROM user_personal_collections WHERE collection_type IN ('playlist','smart')
);
DELETE FROM user_personal_collections WHERE collection_type IN ('playlist','smart');
-- Restore narrow CHECK constraint.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced'));
-- Drop the sub_item_id column.
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
```
The down migration is symmetrically structured but lossy in reverse: rolling back loses any playlist/smart rows created after the cutover. That's acceptable because the abs_* tables were near-empty before the up.
### 5.3 Risk
| Risk | Probability | Mitigation |
|---|---|---|
| Down-migration loses real user data | Low (one row today; grows over time) | Snapshot the table before running down in any prod context |
| ABS app sees wire-shape regressions | Medium | Snapshot tests on JSON output of each ABS endpoint before/after |
| smartcoll DSL gap (silo-native query_definition row that doesn't fit DSL subset) | Low | Pre-migration query enumerates non-conforming rows; spec asserts subset compat |
| `library_collections` UI gains audiobook rows it can't render | Low | UI changes deferred; admin doesn't currently create audiobook-typed library collections |
---
## 6. Open questions
1. **Playlist `cover_item` field.** `abs_playlists.cover_item` is a FK to `media_items.content_id`. `user_personal_collections.poster_url` is a plain text URL. Two options for the migration: (a) drop `cover_item`, regenerate poster URLs from the first item in the playlist; or (b) add a `cover_content_id` column to `user_personal_collections`. Recommend (a) for simplicity — the existing playlist row has no `cover_item` set.
2. **`is_pinned` on smart collections.** `abs_smart_collections.is_pinned` has no analog in `user_personal_collections`. Likely needs a new boolean column; defer until smart-collection UI work names a use case.
3. **`color` on smart collections.** `abs_smart_collections.color` has no analog. Same call as `is_pinned` — defer; the existing 0 rows means no data to preserve.
4. **Admin-curated audiobook collections in the ABS API.** Out of scope per §3, but flagged for a future design.
---
## 7. Testing
- **Migration round-trip test.** Run `up.sql` → `down.sql` → `up.sql` on a fresh test DB seeded with one of each `abs_*` row. Assert canonical-table contents are identical before and after.
- **ABS endpoint wire-shape snapshots.** Generate JSON for `GET /api/libraries/{id}/collections|playlists|smart-collections` before the cutover; assert byte-equal after.
- **smartcoll engine portability.** Move the existing `internal/audiobooks/smartcoll/*_test.go` into `internal/smartcoll/` and add three new tests:
- audiobook-specific rule (`narrator` filter) evaluated against an audiobook → expected items.
- audiobook-specific rule evaluated against a movie → empty set (no-op predicate).
- simple library-id filter (silo-native query_definition shape) evaluated under the unified DSL → expected items.
- **Section recipe regression.** Existing recipes with default `media_types=['movie','series']` return identical results to today. New `continue_listening` and `by_audiobook_series` recipes covered with focused unit tests.
---
## 8. Sub-projects
This design is decomposed for implementation:
1. **Schema migration + canonical storage extensions** — migration 156, CHECK/column additions, no behavior changes yet.
2. **Smart-collection engine lift** — move `internal/audiobooks/smartcoll/` to `internal/smartcoll/`, update imports, no logic changes.
3. **ABS store adapter rewrites** — collection/playlist/smart-collection stores query canonical tables.
4. **Section recipes for audiobooks** — recipe `SupportedMediaTypes` field, two new recipes, fetcher type filter.
5. **Documentation + follow-up tickets** — open-question call-outs (cover_item, is_pinned, color, admin collections in ABS).
Each sub-project has a single-MR scope and ships independently of the others. Sub-project 1 lands first; sub-projects 2–4 can land in any order after it.
---
## 9. Source references
- Existing canonical tables: `internal/usercollections/`, `internal/collections/templates/templates.go`
- Existing smart-collection engine: `internal/audiobooks/smartcoll/`
- Existing section recipes: `internal/sections/recipes/`, `internal/api/handlers/sections_preview.go`, `internal/api/handlers/sections_bulk.go`, `internal/api/handlers/recipes.go`
- Existing ABS store adapters: `internal/audiobooks/abs_collection_store.go`, `internal/audiobooks/abs_playlist_store.go`, `internal/audiobooks/abs_smart_collection_store.go`
- Related shipped specs: `docs/superpowers/specs/2026-05-26-abs-smart-collections-design.md`, `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
+23
View File
@@ -26,9 +26,11 @@ require (
github.com/h2non/bimg v1.1.9
github.com/hashicorp/go-hclog v1.6.3
github.com/joho/godotenv v1.5.1
github.com/mmcdole/gofeed v1.3.0
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
@@ -36,20 +38,41 @@ require (
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.53.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/zishang520/engine.io-go-parser v1.3.2 // indirect
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
require (
+54 -2
View File
@@ -1,9 +1,15 @@
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -50,6 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -68,8 +76,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
@@ -98,6 +109,8 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
@@ -121,6 +134,15 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -144,6 +166,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -174,16 +200,30 @@ github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibgg
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
github.com/zishang520/engine.io-go-parser v1.3.2/go.mod h1:fg/R4V7aytYwUTu4lGcPdjenDSXFWLlkDAGewWVOo3o=
github.com/zishang520/engine.io/v2 v2.5.0 h1:0ayZCt51c8lntxG5AWoM2mX40ryZlvRodAULXB1XK/s=
github.com/zishang520/engine.io/v2 v2.5.0/go.mod h1:ohfMsnzOCA9NEklEGiQ5Y9j6cWvzLNeVFaB+Bkn1KcQ=
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+qUJZo9beZLK8ZXOwKYo=
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -202,6 +242,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
@@ -210,21 +252,31 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+85
View File
@@ -42,6 +42,9 @@ type catalogFiltersResponse struct {
Countries []string `json:"countries"`
OriginalLanguages []string `json:"original_languages"`
ContentRatings []string `json:"content_ratings"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Series []string `json:"series"`
Resolutions *[]string `json:"resolutions,omitempty"`
AudioLanguages *[]string `json:"audio_languages,omitempty"`
SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"`
@@ -163,12 +166,94 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
Countries: filters.Countries,
OriginalLanguages: filters.OriginalLanguages,
ContentRatings: filters.ContentRatings,
Authors: filters.Authors,
Narrators: filters.Narrators,
Series: filters.Series,
Resolutions: resolutions,
AudioLanguages: audioLanguages,
SubtitleLanguages: subtitleLanguages,
})
}
// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on
// the wire. matches[] is always present (empty when no hits); has_more
// is true when the underlying result set held more entries than the
// requested limit.
type catalogFacetSearchResponse struct {
Matches []string `json:"matches"`
HasMore bool `json:"has_more"`
}
// HandleGetCatalogFacetSearch — GET /api/v1/catalog/filters/search
//
// Prefix-typeahead for the high-cardinality filter facets (authors /
// narrators / series, plus genre / studio / network / country /
// original_language / content_rating for consistency). Query
// parameters: same as /api/v1/catalog/filters for scope (source,
// library_id, etc.), plus facet=<name>, q=<prefix>, limit=<N>.
//
// The bulk /api/v1/catalog/filters endpoint stays as the source for
// the initial dropdown render (top 1000 alphabetical); this endpoint
// takes over once the user starts typing.
func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *http.Request) {
if h == nil || h.resolver == nil || h.itemsH == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
req, err := catalog.ParseCatalogRequest(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
facet := strings.TrimSpace(r.URL.Query().Get("facet"))
if facet == "" {
writeError(w, http.StatusBadRequest, "bad_request", "facet parameter is required")
return
}
prefix := r.URL.Query().Get("q")
limit := 20
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
if n > 100 {
n = 100
}
limit = n
}
result, err := h.resolver.SearchFacet(
r.Context(),
req,
h.itemsH.accessFilter(r),
facet,
prefix,
limit,
)
if err != nil {
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search catalog facet")
return
}
matches := result.Matches
if matches == nil {
matches = []string{}
}
writeJSON(w, http.StatusOK, catalogFacetSearchResponse{
Matches: matches,
HasMore: result.HasMore,
})
}
func parseIncludeTechnical(raw string) bool {
if strings.TrimSpace(raw) == "" {
return true
+1 -1
View File
@@ -497,7 +497,7 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode":
case "movie", "episode", "audiobook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
+3 -1
View File
@@ -2036,8 +2036,10 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
levels = []string{"series", "season", "episode"}
case "movies", "movie":
levels = []string{"movie"}
case "audiobooks", "audiobook":
levels = []string{"audiobook"}
case "mixed":
levels = []string{"movie", "series", "season", "episode"}
levels = []string{"movie", "series", "season", "episode", "audiobook"}
default:
return nil
}
@@ -724,6 +724,13 @@ func templateEligibleForLibrary(tmpl templates.Template, library *models.MediaFo
return tmpl.MediaKind == templates.MediaMovie || tmpl.MediaKind == templates.MediaMixed
case "series", "tv", "show", "shows", "tvshows":
return tmpl.MediaKind == templates.MediaTV || tmpl.MediaKind == templates.MediaMixed
case "audiobook", "audiobooks":
// Audiobook libraries only accept audiobook-targeted templates.
// Today the catalog has none (built-ins are TMDB/Trakt/MDBList
// movie/TV imports), so this evaluates to an empty gallery —
// the correct UX. Previously this fell through to default:true
// and offered admins broken movie/TV templates.
return tmpl.MediaKind == templates.MediaAudiobook || tmpl.MediaKind == templates.MediaMixed
default:
return true
}
+139
View File
@@ -0,0 +1,139 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/playback"
)
var ErrServerRestartAlreadyRequested = errors.New("server restart already requested")
type ServerControlHandler struct {
requestRestart func(ctx context.Context) error
commands *playback.CommandDispatcher
}
type serverRestartRequest struct {
Reason string `json:"reason"`
Title string `json:"title"`
Message string `json:"message"`
}
type serverRestartResponse struct {
Status string `json:"status"`
Message string `json:"message"`
NotifiedSessions int `json:"notified_sessions"`
}
func NewServerControlHandler(
requestRestart func(ctx context.Context) error,
commands *playback.CommandDispatcher,
) *ServerControlHandler {
return &ServerControlHandler{
requestRestart: requestRestart,
commands: commands,
}
}
// HandleRestart handles POST /admin/server/restart.
func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Request) {
if h == nil || h.requestRestart == nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Server restart is unavailable")
return
}
var req serverRestartRequest
if err := decodeOptionalJSONBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
notifiedSessions := h.notifyPlaybackSessions(req)
if err := h.requestRestart(context.Background()); err != nil {
if errors.Is(err, ErrServerRestartAlreadyRequested) {
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "already_requested",
Message: "Server restart is already in progress.",
NotifiedSessions: notifiedSessions,
})
return
}
slog.Error("server restart request failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart")
return
}
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "restart_requested",
Message: "Server restart requested. The process will shut down gracefully.",
NotifiedSessions: notifiedSessions,
})
}
func (h *ServerControlHandler) notifyPlaybackSessions(req serverRestartRequest) int {
if h == nil || h.commands == nil {
return 0
}
title := req.Title
if title == "" {
title = "Server restarting"
}
message := req.Message
if message == "" {
message = "The server is restarting now. Playback may reconnect shortly."
}
payload, err := json.Marshal(map[string]string{
"title": title,
"message": message,
})
if err != nil {
slog.Warn("server restart: failed to build realtime payload", "error", err)
return 0
}
results := h.commands.DispatchToAll(func(session *playback.Session) (playback.CommandEnvelope, time.Duration, func(), error) {
if session == nil {
return playback.CommandEnvelope{}, 0, nil, playback.ErrCommandDispatchUnavailable
}
command, err := playback.NewCommandEnvelope(
session.ID,
uuid.NewString(),
playback.CommandServerRestarting,
payload,
)
if err != nil {
return playback.CommandEnvelope{}, 0, nil, err
}
command.Reason = req.Reason
if command.Reason == "" {
command.Reason = "server_restart_requested"
}
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
return command, 0, nil, nil
})
delivered := 0
for _, result := range results {
if result.Delivered {
delivered++
continue
}
if result.DispatchErr != nil && !errors.Is(result.DispatchErr, playback.ErrRealtimeConnectionNotFound) {
slog.Warn("server restart: failed to notify playback session",
"session_id", result.SessionID,
"error", result.DispatchErr,
)
}
}
return delivered
}
@@ -0,0 +1,156 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/playback"
)
type serverControlTestConn struct {
messages []any
}
func (c *serverControlTestConn) WriteJSON(v any) error {
c.messages = append(c.messages, v)
return nil
}
func TestServerControlRestartRequestsShutdown(t *testing.T) {
t.Parallel()
called := 0
handler := NewServerControlHandler(func(context.Context) error {
called++
return nil
}, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if called != 1 {
t.Fatalf("restart calls = %d, want 1", called)
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "restart_requested" {
t.Fatalf("status = %q, want restart_requested", resp.Status)
}
if resp.NotifiedSessions != 0 {
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
}
}
func TestServerControlRestartUnavailable(t *testing.T) {
t.Parallel()
handler := NewServerControlHandler(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestServerControlRestartAlreadyRequested(t *testing.T) {
t.Parallel()
handler := NewServerControlHandler(func(context.Context) error {
return ErrServerRestartAlreadyRequested
}, nil)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Status != "already_requested" {
t.Fatalf("status = %q, want already_requested", resp.Status)
}
}
func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
t.Parallel()
sessionMgr := playback.NewSessionManager(0, 0)
session, err := sessionMgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
realtimeHub := playback.NewRealtimeHub()
conn := &serverControlTestConn{}
registration := realtimeHub.Register(session.ID, conn)
if registration == nil {
t.Fatal("expected realtime registration")
}
defer realtimeHub.Unregister(registration)
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
handler := NewServerControlHandler(func(context.Context) error {
return nil
}, dispatcher)
req := httptest.NewRequest(
http.MethodPost,
"/admin/server/restart",
strings.NewReader(`{"reason":"maintenance","message":"Restarting for maintenance"}`),
)
rec := httptest.NewRecorder()
handler.HandleRestart(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverRestartResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.NotifiedSessions != 1 {
t.Fatalf("notified_sessions = %d, want 1", resp.NotifiedSessions)
}
if len(conn.messages) != 1 {
t.Fatalf("messages = %d, want 1", len(conn.messages))
}
command, ok := conn.messages[0].(playback.CommandEnvelope)
if !ok {
t.Fatalf("message type = %T, want playback.CommandEnvelope", conn.messages[0])
}
if command.Name != playback.CommandServerRestarting {
t.Fatalf("command name = %q, want %q", command.Name, playback.CommandServerRestarting)
}
if command.Reason != "maintenance" {
t.Fatalf("reason = %q, want maintenance", command.Reason)
}
var payload map[string]string
if err := json.Unmarshal(command.Payload, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["message"] != "Restarting for maintenance" {
t.Fatalf("payload message = %q, want custom message", payload["message"])
}
}
+26 -1
View File
@@ -143,6 +143,7 @@ type Dependencies struct {
PlaybackRealtimeHub *playback.RealtimeHub
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
RequestServerRestart func(ctx context.Context) error
// UserCollectionSync handles per-profile imported collections (TMDB /
// Trakt / MDBList) — the user-facing analogue of CollectionService.
@@ -158,10 +159,23 @@ type Dependencies struct {
// (search/top). May be nil; the handlers report "not configured" in
// that case rather than failing.
MDBListClient *mdblist.Client
// ABSHandler is the Audiobookshelf-compatible HTTP handler. When non-nil
// it is mounted at the root router level (not under /api/v1/) so that ABS
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
}
// absHandler is the narrow interface the router needs from the ABS handler.
// Using an interface avoids a direct import of the abs sub-package from router.go.
type absHandler interface {
Mount(r chi.Router)
}
// NewRouter creates a chi.Router with all middleware and routes mounted
// under /api/v1/.
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
// mounted at the root level when deps.ABSHandler is non-nil.
func NewRouter(deps Dependencies) chi.Router {
r := chi.NewRouter()
@@ -563,6 +577,7 @@ func NewRouter(deps Dependencies) chi.Router {
// Build playback handler if session manager is available.
var playbackHandler *handlers.PlaybackHandler
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
var playbackCommandDispatcher *playback.CommandDispatcher
var streamHandler *handlers.StreamHandler
var watchTogetherHandler *handlers.WatchTogetherHandler
if deps.SessionMgr != nil {
@@ -646,6 +661,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.RealtimeHub = realtimeHub
playbackHandler.CommandTracker = commandTracker
playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker)
playbackCommandDispatcher = playbackHandler.CommandDispatcher
playbackHandler.IntroAnalyzer = deps.IntroAnalyzer
playbackHandler.IntroRepository = deps.IntroRepository
playbackHandler.MarkerRegistry = deps.MarkerRegistry
@@ -684,6 +700,8 @@ func NewRouter(deps Dependencies) chi.Router {
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
}
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
// Build admin handler if we have a user repo.
var adminHandler *handlers.AdminHandler
var catalogSeedHandler *handlers.CatalogSeedHandler
@@ -1156,6 +1174,11 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// ABS-compat routes are NOT mounted here — they live on a dedicated
// http.Server (see absCompatSrv in cmd/silo/main.go) so the discovery
// probes (/ping, /healthcheck, /status, etc.) don't collide with the
// SPA fallback. Same pattern as the Jellyfin compat listener on 8096.
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", healthHandler.ServeHTTP)
r.Get("/ready", readyHandler.ServeHTTP)
@@ -1393,6 +1416,7 @@ func NewRouter(deps Dependencies) chi.Router {
if itemsHandler != nil {
r.Get("/catalog", catalogHandler.HandleGetCatalog)
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
if catalogResourceHandler != nil {
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
@@ -1884,6 +1908,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/playback-history", adminHandler.HandleListPlaybackHistory)
r.Get("/unmatched", adminHandler.HandleListUnmatched)
r.Get("/stats", adminHandler.HandleGetStats)
r.Post("/server/restart", serverControlHandler.HandleRestart)
r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus)
r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection)
if sectionSettingsHandler != nil {
+96
View File
@@ -0,0 +1,96 @@
package abs
import (
"bufio"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
)
// accessLog is a minimal chi middleware that emits one structured line
// per request. The 2xx/3xx path logs at Debug so a default-Info runtime
// stays quiet during normal playback; non-2xx escalates to Warn so
// failures still surface without an explicit log-level flip. Path is
// captured query-less so ?token= and refresh tokens never land in logs.
func (h *Handler) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
auth := r.Header.Get("Authorization")
authKind := "none"
switch {
case strings.HasPrefix(auth, "Bearer "):
authKind = "bearer"
case auth != "":
authKind = "other"
case r.URL.Query().Get("token") != "":
authKind = "qtok"
}
// Short-circuit asset requests the mobile app never hits to keep
// the signal-to-noise high.
path := r.URL.Path
if strings.HasPrefix(path, "/assets/") {
return
}
args := []any{
"method", r.Method,
"path", path,
"auth", authKind,
"status", sw.status,
"dur_ms", time.Since(start).Milliseconds(),
}
if sw.status >= 400 {
slog.Warn("abs req failed", args...)
return
}
slog.Debug("abs req", append(args, "bytes", sw.bytes)...)
})
}
// statusRecorder lets the access log read the status code + bytes
// written without re-implementing http.ResponseWriter.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
// Hijack passes through to the wrapped ResponseWriter so socket.io
// WebSocket upgrades can take ownership of the raw connection.
// Without this, the underlying engine.io transport sees a
// ResponseWriter that doesn't satisfy http.Hijacker and rejects the
// upgrade with `{"code":3,"message":"Bad request"}`.
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := s.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("ResponseWriter does not implement http.Hijacker")
}
return h.Hijack()
}
// Flush passes through to the wrapped ResponseWriter so chunked /
// server-sent-events responses (engine.io polling long-poll) flush
// promptly.
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
@@ -0,0 +1,92 @@
package abs
import (
"errors"
"log/slog"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
slog.Warn("abs author access resolution failed", "err", err, "id", id)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, access)
if errors.Is(err, ErrNotFound) {
http.Error(w, "author not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs author detail failed", "err", err, "id", id)
http.Error(w, "author get failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, authorToABS(author))
}
func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
idRaw := chi.URLParam(r, "id")
id, err := url.PathUnescape(idRaw)
if err != nil {
id = idRaw
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
slog.Warn("abs series access resolution failed", "err", err, "id", id)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
series, err := h.deps.MediaStore.GetSeriesByName(r.Context(), id, access)
if errors.Is(err, ErrNotFound) {
http.Error(w, "series not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs series detail failed", "err", err, "id", id)
http.Error(w, "series get failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, seriesToABS(series))
}
func authorToABS(a Author) map[string]any {
books := make([]map[string]any, 0, len(a.Books))
for _, b := range a.Books {
books = append(books, map[string]any{"id": b.ContentID, "media": map[string]any{"metadata": map[string]any{"title": b.Title}}})
}
return map[string]any{
"id": a.ID,
"name": a.Name,
"numBooks": len(a.Books),
"books": books,
}
}
func seriesToABS(s Series) map[string]any {
books := make([]map[string]any, 0, len(s.Books))
for _, b := range s.Books {
books = append(books, map[string]any{"id": b.ContentID, "media": map[string]any{"metadata": map[string]any{"title": b.Title}}})
}
return map[string]any{
"id": s.ID,
"name": s.Name,
"numBooks": len(s.Books),
"books": books,
}
}
@@ -0,0 +1,103 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type authorSeriesStubMediaStore struct {
noopMediaStore
author Author
series Series
}
func (s *authorSeriesStubMediaStore) GetAuthorByID(_ context.Context, id string, _ catalog.AccessFilter) (Author, error) {
if id != s.author.ID {
return Author{}, ErrNotFound
}
return s.author, nil
}
func (s *authorSeriesStubMediaStore) GetSeriesByName(_ context.Context, name string, _ catalog.AccessFilter) (Series, error) {
if name != s.series.ID && name != s.series.Name {
return Series{}, ErrNotFound
}
return s.series, nil
}
func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
media := &authorSeriesStubMediaStore{
author: Author{ID: "42", Name: "Brandon Sanderson", Books: []*models.MediaItem{
{ContentID: "book-1", Title: "Mistborn"},
{ContentID: "book-2", Title: "Stormlight"},
}},
}
h := New(Dependencies{MediaStore: media})
rec := dispatchABSWithParams(http.MethodGet, "/api/authors/42", map[string]string{"id": "42"}, nil, "1", "", h.handleAuthorDetail)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if got["name"] != "Brandon Sanderson" {
t.Errorf("name = %v", got["name"])
}
books, _ := got["books"].([]any)
if len(books) != 2 {
t.Errorf("books len = %d, want 2", len(books))
}
}
func TestAuthor_Detail_Unknown_404(t *testing.T) {
media := &authorSeriesStubMediaStore{author: Author{ID: "42"}}
h := New(Dependencies{MediaStore: media})
rec := dispatchABSWithParams(http.MethodGet, "/api/authors/99", map[string]string{"id": "99"}, nil, "1", "", h.handleAuthorDetail)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestSeries_Detail_ReturnsBooks(t *testing.T) {
media := &authorSeriesStubMediaStore{
series: Series{ID: "mistborn", Name: "Mistborn", Books: []*models.MediaItem{
{ContentID: "b1", Title: "Final Empire"},
{ContentID: "b2", Title: "Well of Ascension"},
}},
}
h := New(Dependencies{MediaStore: media})
rec := dispatchABSWithParams(http.MethodGet, "/api/series/mistborn", map[string]string{"id": "mistborn"}, nil, "1", "", h.handleSeriesDetail)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if got["name"] != "Mistborn" {
t.Errorf("name = %v", got["name"])
}
books, _ := got["books"].([]any)
if len(books) != 2 {
t.Errorf("books len = %d, want 2", len(books))
}
}
func TestSeries_Detail_Unknown_404(t *testing.T) {
media := &authorSeriesStubMediaStore{series: Series{ID: "mistborn", Name: "Mistborn"}}
h := New(Dependencies{MediaStore: media})
rec := dispatchABSWithParams(http.MethodGet, "/api/series/unknown", map[string]string{"id": "unknown"}, nil, "1", "", h.handleSeriesDetail)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
+54
View File
@@ -0,0 +1,54 @@
package abs
import (
"context"
"time"
)
// BookmarkStore is the narrow slice of the abs_bookmarks table the
// bookmarks handlers need. Implemented by ABSBookmarkStore in
// internal/audiobooks/abs_bookmark_store.go.
type BookmarkStore interface {
// List returns all bookmarks for (user, profile, item) ordered by
// time ASC. Returns an empty slice (never nil) when none exist.
List(ctx context.Context, userID, profileID, itemID string) ([]Bookmark, error)
// Upsert inserts a bookmark or updates the title at the exact
// (user, profile, item, time) tuple. ID is generated on insert and
// preserved on update. Returns the resulting row.
Upsert(ctx context.Context, userID, profileID, itemID string, timeSeconds float64, title string) (Bookmark, error)
// Delete removes the bookmark at (user, profile, item, time).
// Returns nil when no row matched — DELETE is idempotent (a UX
// convenience, not a 404 surface). See spec §6.
Delete(ctx context.Context, userID, profileID, itemID string, timeSeconds float64) error
// CountByUser returns a map of library_item_id -> bookmark count
// for the given (user, profile). Empty map (never nil) when none.
// Used by the smart-collection items evaluator to hydrate the
// `bookmark_count` personalized rule in one SQL pass.
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
}
// Bookmark is the in-memory representation of an abs_bookmarks row as
// the handlers use it. Intentionally narrow — only the fields the wire
// format cares about.
type Bookmark struct {
ID string // ULID
LibraryItemID string
Time float64 // fractional seconds
Title string
CreatedAt time.Time
UpdatedAt time.Time
}
// bookmarkToABS shapes a Bookmark into the ABS wire format the Android
// and iOS clients expect. All six keys are always present (no
// omitempty), camelCase, with timestamps as JS-epoch milliseconds.
func bookmarkToABS(b Bookmark) map[string]any {
return map[string]any{
"id": b.ID,
"libraryItemId": b.LibraryItemID,
"time": b.Time,
"title": b.Title,
"createdAt": b.CreatedAt.UnixMilli(),
"updatedAt": b.UpdatedAt.UnixMilli(),
}
}
@@ -0,0 +1,45 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"time"
)
// TestBookmarkEnvelope_HasRequiredKeys asserts the wire shape ABS Android
// builds against: id, libraryItemId, time, title, createdAt, updatedAt,
// all camelCase and all present (no omitempty), including when title is
// empty — Android shows an "Untitled" placeholder client-side rather
// than treating missing-title differently from empty-title.
func TestBookmarkEnvelope_HasRequiredKeys(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
out := bookmarkToABS(Bookmark{
ID: "01HXX",
LibraryItemID: "126887",
Time: 1234.5,
Title: "",
CreatedAt: now,
UpdatedAt: now,
})
body, err := json.Marshal(out)
if err != nil {
t.Fatalf("marshal: %v", err)
}
js := string(body)
for _, key := range []string{
`"id":`, `"libraryItemId":`, `"time":`, `"title":`,
`"createdAt":`, `"updatedAt":`,
} {
if !strings.Contains(js, key) {
t.Errorf("envelope missing %s; got %s", key, js)
}
}
if out["title"] != "" {
t.Errorf("title = %v, want empty string", out["title"])
}
wantMs := now.UnixMilli()
if out["createdAt"] != wantMs {
t.Errorf("createdAt = %v, want %d (UnixMilli)", out["createdAt"], wantMs)
}
}
@@ -0,0 +1,182 @@
package abs
import (
"encoding/json"
"io"
"log/slog"
"math"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
// bookmarkBody is the JSON body for POST and PATCH
// /me/item/{itemId}/bookmark. Time is a pointer so we can distinguish
// missing (→ 400) from the literal 0.0.
type bookmarkBody struct {
Title string `json:"title"`
Time *float64 `json:"time"`
}
// handleUpsertBookmark backs both POST (reason="bookmark_created") and
// PATCH (reason="bookmark_updated") /me/item/{itemId}/bookmark. Both
// share the exact same upsert semantics — only the realtime event
// reason differs.
func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.BookmarkStore == nil {
http.Error(w, "bookmark store unavailable", http.StatusServiceUnavailable)
return
}
itemID := chi.URLParam(r, "itemId")
if itemID == "" {
http.Error(w, "itemId required", http.StatusBadRequest)
return
}
// 1 MiB body cap — matches handleStandaloneLogin.
var body bookmarkBody
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := dec.Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Time == nil || math.IsNaN(*body.Time) {
http.Error(w, "time required", http.StatusBadRequest)
return
}
// Item validation: avoid orphan bookmark rows whose item no
// longer exists. Skipped on DELETE (see handleDeleteBookmark).
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
if err != nil {
slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "item lookup failed", http.StatusInternalServerError)
return
}
if item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
bm, err := h.deps.BookmarkStore.Upsert(r.Context(), a.UserID, a.ProfileID, itemID, *body.Time, body.Title)
if err != nil {
slog.Error("abs bookmark upsert failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "bookmark persist failed", http.StatusInternalServerError)
return
}
h.publish(a.UserID, "user_updated", map[string]any{
"reason": reason,
"bookmark": bookmarkToABS(bm),
})
writeBookmarkList(w, r, h, a.UserID, a.ProfileID, itemID)
}
}
// handleDeleteBookmark — DELETE /me/item/{itemId}/bookmark/{time}.
//
// Idempotent: returns 200 with the caller's current bookmark list,
// whether or not the (item, time) row existed. Crucially, this means
// a DELETE against another user's bookmark returns the caller's own
// (empty-or-other) list — no enumeration vector.
//
// Item validation is intentionally skipped: a bookmark whose item was
// just deleted should still be removable. (Upsert keeps validation
// because it would create a new orphan row.)
func (h *Handler) handleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.BookmarkStore == nil {
http.Error(w, "bookmark store unavailable", http.StatusServiceUnavailable)
return
}
itemID := chi.URLParam(r, "itemId")
if itemID == "" {
http.Error(w, "itemId required", http.StatusBadRequest)
return
}
t, ok := parseBookmarkTime(chi.URLParam(r, "time"))
if !ok {
http.Error(w, "time required", http.StatusBadRequest)
return
}
// Snapshot the pre-delete row so the realtime payload carries the
// title that just got removed (clients prefer this over a bare ID).
var pre Bookmark
if rows, err := h.deps.BookmarkStore.List(r.Context(), a.UserID, a.ProfileID, itemID); err == nil {
for _, b := range rows {
if b.Time == t {
pre = b
break
}
}
}
if err := h.deps.BookmarkStore.Delete(r.Context(), a.UserID, a.ProfileID, itemID, t); err != nil {
slog.Error("abs bookmark delete failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "bookmark delete failed", http.StatusInternalServerError)
return
}
// Only publish when the row actually existed (pre.ID is empty
// otherwise). Avoids notifying other devices about a phantom delete.
if pre.ID != "" {
h.publish(a.UserID, "user_updated", map[string]any{
"reason": "bookmark_deleted",
"bookmark": bookmarkToABS(pre),
})
}
writeBookmarkList(w, r, h, a.UserID, a.ProfileID, itemID)
}
// parseBookmarkTime parses the {time} URL parameter on DELETE
// /me/item/{itemId}/bookmark/{time}. Returns (0, false) on parse
// failure.
func parseBookmarkTime(s string) (float64, bool) {
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return 0, false
}
return v, true
}
// writeBookmarkList re-fetches the item's bookmarks and writes them as
// the JSON response. On list-fetch failure after a successful mutation,
// degrade to 200 + empty list + slog.Warn (the mutation already
// committed; failing the response would mis-report the state).
func writeBookmarkList(w http.ResponseWriter, r *http.Request, h *Handler, userID, profileID, itemID string) {
rows, err := h.deps.BookmarkStore.List(r.Context(), userID, profileID, itemID)
if err != nil {
slog.Warn("abs bookmark list after mutation failed", "err", err, "user", userID, "item", itemID)
writeJSON(w, http.StatusOK, []any{})
return
}
out := make([]map[string]any, 0, len(rows))
for _, b := range rows {
out = append(out, bookmarkToABS(b))
}
writeJSON(w, http.StatusOK, out)
}
@@ -0,0 +1,511 @@
package abs
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// In-memory fakes
// ---------------------------------------------------------------------------
// memBookmarkStore is an in-memory BookmarkStore for handler tests.
// Keyed on (userID, profileID, itemID, time) to mirror the SQL unique
// index. Thread-safe so parallel sub-tests can share an instance.
type memBookmarkStore struct {
mu sync.Mutex
rows map[string]Bookmark // key = userID|profileID|itemID|time
seq int // monotonic counter for deterministic IDs in tests
}
func newMemBookmarkStore() *memBookmarkStore {
return &memBookmarkStore{rows: map[string]Bookmark{}}
}
func bkKey(userID, profileID, itemID string, t float64) string {
return userID + "|" + profileID + "|" + itemID + "|" + formatTime(t)
}
func formatTime(t float64) string {
// Round-trip-safe encoding for map keys. Postgres compares float8
// bit-for-bit too, so this matches production semantics.
b, _ := json.Marshal(t)
return string(b)
}
// List iterates the keyed map directly so a row only matches when ALL
// of (user, profile, item) line up. Iterating values and reconstructing
// the key would be ambiguous when two users have a bookmark at the
// same (item, time).
func (m *memBookmarkStore) List(_ context.Context, userID, profileID, itemID string) ([]Bookmark, error) {
m.mu.Lock()
defer m.mu.Unlock()
prefix := userID + "|" + profileID + "|" + itemID + "|"
out := make([]Bookmark, 0)
for k, b := range m.rows {
if strings.HasPrefix(k, prefix) {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time })
return out, nil
}
func (m *memBookmarkStore) Upsert(_ context.Context, userID, profileID, itemID string, t float64, title string) (Bookmark, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := bkKey(userID, profileID, itemID, t)
now := time.Now()
if existing, ok := m.rows[key]; ok {
existing.Title = title
existing.UpdatedAt = now
m.rows[key] = existing
return existing, nil
}
m.seq++
b := Bookmark{
ID: "01HTEST" + formatSeq(m.seq),
LibraryItemID: itemID,
Time: t,
Title: title,
CreatedAt: now,
UpdatedAt: now,
}
m.rows[key] = b
return b, nil
}
func (m *memBookmarkStore) Delete(_ context.Context, userID, profileID, itemID string, t float64) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, bkKey(userID, profileID, itemID, t))
return nil
}
func (m *memBookmarkStore) CountByUser(_ context.Context, userID, profileID string) (map[string]int, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := map[string]int{}
prefix := userID + "|" + profileID + "|"
for k := range m.rows {
if strings.HasPrefix(k, prefix) {
rest := k[len(prefix):]
sep := -1
for i, c := range rest {
if c == '|' {
sep = i
break
}
}
if sep < 0 {
continue
}
itemID := rest[:sep]
out[itemID]++
}
}
return out, nil
}
func formatSeq(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
// recordingPublisher captures publish() calls so tests can assert socket
// event semantics without wiring a real Socket.io server.
type recordingPublisher struct {
mu sync.Mutex
events []publishedEvent
}
type publishedEvent struct {
UserID string
Event string
Payload any
}
func (p *recordingPublisher) Publish(userID, event string, payload any) {
p.mu.Lock()
defer p.mu.Unlock()
p.events = append(p.events, publishedEvent{UserID: userID, Event: event, Payload: payload})
}
func (p *recordingPublisher) Broadcast(_ string, _ any) {}
func (p *recordingPublisher) snapshot() []publishedEvent {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]publishedEvent, len(p.events))
copy(out, p.events)
return out
}
// stubMediaStore satisfies MediaStore with a configurable item lookup so
// handler tests can drive both the 200 and 404 branches.
type stubMediaStore struct {
noopMediaStore
known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed")
lookupErr error
}
func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string, _ catalog.AccessFilter) (*models.MediaItem, error) {
if s.lookupErr != nil {
return nil, s.lookupErr
}
if it, ok := s.known[id]; ok {
if it == nil {
return &models.MediaItem{ContentID: id}, nil
}
return it, nil
}
return nil, nil
}
func (s *stubMediaStore) GetAuthorByID(_ context.Context, id string, _ catalog.AccessFilter) (Author, error) {
return Author{}, ErrNotFound
}
func (s *stubMediaStore) GetSeriesByName(_ context.Context, name string, _ catalog.AccessFilter) (Series, error) {
return Series{}, ErrNotFound
}
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
type bookmarksHarness struct {
H *Handler
Pub *recordingPublisher
Book *memBookmarkStore
}
func newBookmarksHarness(t *testing.T, knownItems ...string) *bookmarksHarness {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil // exists, body content not used by handlers
}
pub := &recordingPublisher{}
store := newMemBookmarkStore()
h := New(Dependencies{
MediaStore: &stubMediaStore{known: known},
BookmarkStore: store,
Publisher: pub,
})
return &bookmarksHarness{H: h, Pub: pub, Book: store}
}
// dispatchBookmark drives a bookmarks handler directly. Injects ctxAuth
// (the bearerAuth middleware's product) and chi route params so the
// handler can read both via absAuthFrom() and chi.URLParam() without
// running the full middleware chain.
func dispatchBookmark(h *Handler, method, path, itemID, timeParam string, body []byte, userID, profileID string, fn http.HandlerFunc) *httptest.ResponseRecorder {
var rd *bytes.Reader
if body != nil {
rd = bytes.NewReader(body)
}
var req *http.Request
if rd != nil {
req = httptest.NewRequest(method, path, rd)
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
rctx := chi.NewRouteContext()
if itemID != "" {
rctx.URLParams.Add("itemId", itemID)
}
if timeParam != "" {
rctx.URLParams.Add("time", timeParam)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID, ProfileID: profileID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
fn(rec, req)
return rec
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestCreate_NewBookmark_ReturnsListContainingIt(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
body := []byte(`{"title":"Chapter cliffhanger","time":42.5}`)
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", body, "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var list []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if len(list) != 1 {
t.Fatalf("list len = %d, want 1; body=%s", len(list), rec.Body.String())
}
got := list[0]
if got["libraryItemId"] != "book-1" {
t.Errorf("libraryItemId = %v, want book-1", got["libraryItemId"])
}
if got["time"] != 42.5 {
t.Errorf("time = %v, want 42.5", got["time"])
}
if got["title"] != "Chapter cliffhanger" {
t.Errorf("title = %v, want Chapter cliffhanger", got["title"])
}
for _, k := range []string{"id", "createdAt", "updatedAt"} {
if _, ok := got[k]; !ok {
t.Errorf("response missing %q; body=%s", k, rec.Body.String())
}
}
}
func TestUpsert_SameTime_UpdatesTitleNoDuplicate(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
// POST first.
postBody := []byte(`{"title":"first","time":10}`)
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", postBody, "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusOK {
t.Fatalf("POST status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var postList []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &postList)
if len(postList) != 1 {
t.Fatalf("after POST list len = %d, want 1", len(postList))
}
firstID := postList[0]["id"]
// PATCH at the same time with a new title.
patchBody := []byte(`{"title":"renamed","time":10}`)
rec2 := dispatchBookmark(hb.H, http.MethodPatch, "/api/me/item/book-1/bookmark", "book-1", "", patchBody, "1", "", hb.H.handleUpsertBookmark("bookmark_updated"))
if rec2.Code != http.StatusOK {
t.Fatalf("PATCH status = %d, want 200; body=%s", rec2.Code, rec2.Body.String())
}
var patchList []map[string]any
if err := json.Unmarshal(rec2.Body.Bytes(), &patchList); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec2.Body.String())
}
if len(patchList) != 1 {
t.Fatalf("after PATCH list len = %d, want 1 (upsert, not insert)", len(patchList))
}
if patchList[0]["title"] != "renamed" {
t.Errorf("title = %v, want renamed", patchList[0]["title"])
}
if patchList[0]["id"] != firstID {
t.Errorf("id changed across upsert: was %v, now %v (id must be preserved)", firstID, patchList[0]["id"])
}
}
func TestDelete_ExistingBookmark_RemovedFromList(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
// Seed a bookmark via POST.
postBody := []byte(`{"title":"to delete","time":99}`)
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", postBody, "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
// DELETE it.
rec := dispatchBookmark(hb.H, http.MethodDelete, "/api/me/item/book-1/bookmark/99", "book-1", "99", nil, "1", "", hb.H.handleDeleteBookmark)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var list []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if len(list) != 0 {
t.Errorf("list len = %d, want 0; body=%s", len(list), rec.Body.String())
}
}
func TestDelete_NonExistentTime_IdempotentReturnsEmptyList(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
rec := dispatchBookmark(hb.H, http.MethodDelete, "/api/me/item/book-1/bookmark/123", "book-1", "123", nil, "1", "", hb.H.handleDeleteBookmark)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (idempotent); body=%s", rec.Code, rec.Body.String())
}
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 0 {
t.Errorf("list len = %d, want 0", len(list))
}
}
func TestCreate_TwoAtDifferentTimes_ListOrderedByTime(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"later","time":100}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"earlier","time":50}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 2 {
t.Fatalf("list len = %d, want 2", len(list))
}
if list[0]["time"] != float64(50) || list[1]["time"] != float64(100) {
t.Errorf("list times = [%v, %v], want [50, 100]", list[0]["time"], list[1]["time"])
}
}
func TestProfileIsolation_BookmarksScopedPerProfile(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
// Profile A inserts.
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"a","time":1}`), "1", "00000000-0000-0000-0000-0000000000aa", hb.H.handleUpsertBookmark("bookmark_created"))
// Profile B (same user) reads via POST at a different time so we get the
// list back. Profile B's POST should return only profile B's bookmarks.
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"b","time":2}`), "1", "00000000-0000-0000-0000-0000000000bb", hb.H.handleUpsertBookmark("bookmark_created"))
var list []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if len(list) != 1 {
t.Fatalf("profile B list len = %d, want 1 (isolation broken)", len(list))
}
if list[0]["title"] != "b" {
t.Errorf("profile B saw profile A's bookmark: %v", list[0])
}
}
func TestDelete_OtherUserBookmark_NoOpAndNoExistenceLeak(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
// User B seeds a bookmark.
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"B's bookmark","time":42.5}`), "2", "", hb.H.handleUpsertBookmark("bookmark_created"))
// User A tries to DELETE at the same item+time.
rec := dispatchBookmark(hb.H, http.MethodDelete, "/api/me/item/book-1/bookmark/42.5", "book-1", "42.5", nil, "1", "", hb.H.handleDeleteBookmark)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (no leak); body=%s", rec.Code, rec.Body.String())
}
var aList []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &aList)
if len(aList) != 0 {
t.Errorf("user A response list = %v, want empty", aList)
}
// User B's bookmark must still be there.
bList, err := hb.Book.List(context.Background(), "2", "", "book-1")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(bList) != 1 {
t.Errorf("user B bookmarks = %d, want 1 (was wrongly deleted)", len(bList))
}
}
func TestMissingItem_404(t *testing.T) {
hb := newBookmarksHarness(t /* no known items */)
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/unknown/bookmark", "unknown", "", []byte(`{"title":"x","time":1}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestItemLookup_ErrorFromMediaStore_500(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
hb.H.deps.MediaStore = &stubMediaStore{
known: map[string]*models.MediaItem{"book-1": nil},
lookupErr: errors.New("media lookup failed"),
}
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
}
}
func TestInvalidBody_400(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{not json`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestMissingTime_400(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"no time"}`), "1", "", hb.H.handleUpsertBookmark("bookmark_created"))
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func assertOneEvent(t *testing.T, pub *recordingPublisher, wantUser, wantReason string) {
t.Helper()
evts := pub.snapshot()
if len(evts) != 1 {
t.Fatalf("publisher events = %d, want 1: %+v", len(evts), evts)
}
e := evts[0]
if e.UserID != wantUser {
t.Errorf("event userID = %q, want %q", e.UserID, wantUser)
}
if e.Event != "user_updated" {
t.Errorf("event name = %q, want user_updated", e.Event)
}
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("payload type = %T, want map[string]any", e.Payload)
}
if payload["reason"] != wantReason {
t.Errorf("reason = %v, want %q", payload["reason"], wantReason)
}
if _, ok := payload["bookmark"].(map[string]any); !ok {
t.Errorf("bookmark payload missing or wrong type: %T", payload["bookmark"])
}
}
func TestSocketEvent_FiredOnCreate(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "7", "", hb.H.handleUpsertBookmark("bookmark_created"))
assertOneEvent(t, hb.Pub, "7", "bookmark_created")
}
func TestSocketEvent_FiredOnUpdate(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
// Seed (publishes a create event); then PATCH and only assert the
// second event.
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "7", "", hb.H.handleUpsertBookmark("bookmark_created"))
_ = dispatchBookmark(hb.H, http.MethodPatch, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"y","time":1}`), "7", "", hb.H.handleUpsertBookmark("bookmark_updated"))
evts := hb.Pub.snapshot()
if len(evts) != 2 {
t.Fatalf("publisher events = %d, want 2", len(evts))
}
payload := evts[1].Payload.(map[string]any)
if payload["reason"] != "bookmark_updated" {
t.Errorf("second event reason = %v, want bookmark_updated", payload["reason"])
}
}
func TestSocketEvent_FiredOnDelete(t *testing.T) {
hb := newBookmarksHarness(t, "book-1")
_ = dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", []byte(`{"title":"x","time":1}`), "7", "", hb.H.handleUpsertBookmark("bookmark_created"))
_ = dispatchBookmark(hb.H, http.MethodDelete, "/api/me/item/book-1/bookmark/1", "book-1", "1", nil, "7", "", hb.H.handleDeleteBookmark)
evts := hb.Pub.snapshot()
if len(evts) != 2 {
t.Fatalf("publisher events = %d, want 2 (create + delete)", len(evts))
}
payload := evts[1].Payload.(map[string]any)
if payload["reason"] != "bookmark_deleted" {
t.Errorf("delete event reason = %v, want bookmark_deleted", payload["reason"])
}
bm, _ := payload["bookmark"].(map[string]any)
if bm["title"] != "x" {
t.Errorf("delete payload title = %v, want 'x' (pre-delete snapshot)", bm["title"])
}
}
+86
View File
@@ -0,0 +1,86 @@
package abs
import "strings"
// CollapseBySeries folds a flat list of LibraryItems into a deduplicated
// list where every item belonging to a series is represented by a single
// entry carrying a CollapsedSeriesV1 block listing every book in that
// series. Items with no series pass through unchanged.
//
// The representative entry for a series is the first item in source
// order — same convention real ABS uses. The series itself is keyed by
// the first series.ID on each book (real ABS books rarely belong to
// multiple series; the spec defines this as taking the first when they
// do).
//
// Stable across multiple calls: input order determines output order, so
// pagination on top of this remains deterministic.
func CollapseBySeries(items []LibraryItem) []LibraryItem {
if len(items) == 0 {
return items
}
// Index series → representative slot (+ tracked ID list).
seriesSlot := make(map[string]int, len(items))
out := make([]LibraryItem, 0, len(items))
for _, it := range items {
series := primarySeries(it)
if series.ID == "" && series.Name == "" {
// Not part of a series — pass through.
out = append(out, it)
continue
}
key := seriesKey(series)
if slot, ok := seriesSlot[key]; ok {
out[slot].CollapsedSeries.NumBooks++
out[slot].CollapsedSeries.LibraryItemIDs = append(
out[slot].CollapsedSeries.LibraryItemIDs, it.ID)
continue
}
// First sighting — clone the item and attach a fresh
// CollapsedSeriesV1 with this id as the seed.
rep := it
rep.CollapsedSeries = &CollapsedSeriesV1{
ID: series.ID,
Name: series.Name,
NameIgnorePrefix: stripLeadingArticle(series.Name),
NumBooks: 1,
LibraryItemIDs: []string{it.ID},
}
seriesSlot[key] = len(out)
out = append(out, rep)
}
return out
}
// primarySeries returns the first series ref on a LibraryItem, or a
// zero-value SeriesObj when the book is unaffiliated.
func primarySeries(it LibraryItem) SeriesObj {
for _, s := range it.Media.Metadata.Series {
if s.ID != "" || s.Name != "" {
return s
}
}
return SeriesObj{}
}
// seriesKey prefers the id (stable identifier); falls back to the name
// when the id is empty (legacy / synthesised metadata).
func seriesKey(s SeriesObj) string {
if s.ID != "" {
return "id:" + s.ID
}
return "name:" + s.Name
}
// stripLeadingArticle produces the "ignore-prefix" sort label real ABS
// emits for series — "The Stormlight Archive" sorts under S, not T.
func stripLeadingArticle(name string) string {
lower := strings.ToLower(name)
for _, prefix := range []string{"the ", "a ", "an "} {
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(name[len(prefix):])
}
}
return name
}
+83
View File
@@ -0,0 +1,83 @@
package abs
import (
"context"
"time"
)
// CollectionStore is the narrow slice of user_personal_collections
// (collection_type='manual') and user_personal_collection_items
// (sub_item_id='') the collections handlers need. Implemented by
// ABSCollectionStore in internal/audiobooks/abs_collection_store.go;
// post-migration-156 it reads the unified canonical tables.
type CollectionStore interface {
// ListUserCollections returns collections owned by (userID, profileID),
// ordered by created_at DESC. Empty slice (never nil) when none.
ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error)
// GetCollection fetches by ID without owner check (caller authorizes).
// Returns ErrNotFound when absent.
GetCollection(ctx context.Context, id string) (Collection, error)
// CreateCollection inserts. ID must be set by caller (ULID).
CreateCollection(ctx context.Context, c Collection) error
// UpdateCollection writes name, description, is_public; bumps
// updated_at = now(). Owner check is the caller's responsibility.
UpdateCollection(ctx context.Context, c Collection) error
// DeleteCollection removes the collection and (via FK CASCADE) all
// its user_personal_collection_items. Returns nil even if no row
// matched.
DeleteCollection(ctx context.Context, id string) error
// ListCollectionItems returns items ordered by added_at ASC.
// Empty slice (never nil) when none.
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
// AddCollectionItem inserts (collectionID, libraryItemID) and bumps
// the parent's updated_at. ON CONFLICT DO NOTHING — re-adding is a
// silent no-op.
AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
// RemoveCollectionItem deletes one row and bumps the parent's
// updated_at. Returns nil when not present (idempotent).
RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
}
// Collection is the in-memory representation of a
// user_personal_collections row with collection_type='manual'.
type Collection struct {
ID string
UserID string
ProfileID string
Name string
Description string
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
// CollectionItem is the in-memory representation of a
// user_personal_collection_items row scoped to a manual collection
// (sub_item_id='').
type CollectionItem struct {
CollectionID string
LibraryItemID string
AddedAt time.Time
}
// collectionToABS shapes a Collection in the ABS wire format. When
// books is nil the list-shape is emitted (no "books" key); when books
// is non-nil (possibly empty) the full-shape is emitted.
//
// All seven non-books keys are always present (no omitempty),
// camelCase, with timestamps as JS-epoch milliseconds.
func collectionToABS(c Collection, books []map[string]any) map[string]any {
out := map[string]any{
"id": c.ID,
"userId": c.UserID,
"name": c.Name,
"description": c.Description,
"isPublic": c.IsPublic,
"lastUpdate": c.UpdatedAt.UnixMilli(),
"createdAt": c.CreatedAt.UnixMilli(),
}
if books != nil {
out["books"] = books
}
return out
}
@@ -0,0 +1,55 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"time"
)
// TestCollectionEnvelope_HasRequiredKeys asserts the seven top-level
// keys ABS Android pattern-matches on are present even when description
// is empty and books[] is empty. Fixes the continuum-reference bug where
// description always emitted as "" regardless of stored value.
func TestCollectionEnvelope_HasRequiredKeys(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
out := collectionToABS(Collection{
ID: "01HCOLL",
UserID: "1",
Name: "Favorites",
Description: "",
IsPublic: false,
CreatedAt: now,
UpdatedAt: now,
}, []map[string]any{})
body, _ := json.Marshal(out)
js := string(body)
for _, key := range []string{
`"id":`, `"userId":`, `"name":`, `"description":`,
`"isPublic":`, `"lastUpdate":`, `"createdAt":`, `"books":`,
} {
if !strings.Contains(js, key) {
t.Errorf("envelope missing %s; got %s", key, js)
}
}
if out["description"] != "" {
t.Errorf("description = %v, want empty string", out["description"])
}
wantMs := now.UnixMilli()
if out["createdAt"] != wantMs {
t.Errorf("createdAt = %v, want %d", out["createdAt"], wantMs)
}
}
// TestCollectionListShape_OmitsBooks asserts the list shape (passed
// nil books) emits no "books" key — clients distinguish list vs detail
// by presence/absence of this field.
func TestCollectionListShape_OmitsBooks(t *testing.T) {
out := collectionToABS(Collection{
ID: "01HCOLL", UserID: "1", Name: "x",
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}, nil)
if _, ok := out["books"]; ok {
t.Errorf("list-shape must not include books key; got %v", out)
}
}
@@ -0,0 +1,435 @@
package abs
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
)
// collectionBody is the JSON body for POST and PATCH /collections[/{id}].
// All fields are optional on PATCH; name is required on POST (checked
// in the handler, not via tag-driven validation).
type collectionBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsPublic *bool `json:"isPublic"`
}
// handleCreateCollection — POST /collections.
// Body: {name, description?, isPublic?}. Returns the created collection
// in full-shape (with an empty books[] array).
func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection store unavailable", http.StatusServiceUnavailable)
return
}
var body collectionBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name == nil {
http.Error(w, "name required", http.StatusBadRequest)
return
}
name := strings.TrimSpace(*body.Name)
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
c := Collection{
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
Name: name,
}
if body.Description != nil {
c.Description = *body.Description
}
if body.IsPublic != nil {
c.IsPublic = *body.IsPublic
}
if err := h.deps.CollectionStore.CreateCollection(r.Context(), c); err != nil {
slog.Error("abs collection create failed", "err", err, "user", a.UserID)
http.Error(w, "collection persist failed", http.StatusInternalServerError)
return
}
// Re-fetch to pick up server-set timestamps.
persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), c.ID)
if errors.Is(err, ErrNotFound) {
persisted = c
} else if err != nil {
slog.Warn("abs collection get-after-create failed", "err", err, "id", c.ID)
persisted = c
}
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
}
// collectionFullShape renders a Collection in full-shape, hydrating
// books[] via MediaStore. Errors during hydration degrade to bare
// {id, libraryId} entries so the response always reflects DB truth.
func (h *Handler) collectionFullShape(r *http.Request, c Collection) map[string]any {
books := h.collectionBooks(r, c.ID)
return collectionToABS(c, books)
}
// collectionBooks resolves the items in a collection to wire-shape book
// entries. Each entry is a full LibraryItem (id, libraryId, mediaType,
// media{coverPath, metadata...}, ...) — LazyCollectionCard renders the
// cover stack via CollectionCover, which reads book.media.coverPath
// through the globals/getLibraryItemCoverSrc getter. Bare {id, title}
// entries make the cover stack empty.
func (h *Handler) collectionBooks(r *http.Request, collectionID string) []map[string]any {
if h.deps.CollectionStore == nil {
return []map[string]any{}
}
rows, err := h.deps.CollectionStore.ListCollectionItems(r.Context(), collectionID)
if err != nil {
slog.Warn("abs collection list-items failed", "err", err, "collection", collectionID)
return []map[string]any{}
}
lib := h.resolveDefaultLibrary(r.Context())
baseURL := h.absBaseURL(r)
access, _, _ := h.accessFilterFromRequest(r)
out := make([]map[string]any, 0, len(rows))
for _, it := range rows {
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access)
if err != nil || item == nil {
// Defensive: include a stub so the client still sees the
// item count, but with empty media so it falls through to
// the placeholder cover instead of crashing on
// `media.coverPath`.
out = append(out, map[string]any{
"id": it.LibraryItemID,
"libraryId": audiobookLibraryID(lib),
"mediaType": LibraryMediaType,
"media": map[string]any{"metadata": map[string]any{"title": ""}, "coverPath": ""},
})
continue
}
out = append(out, libraryItemToWireMap(siloItemToLibraryItem(item, lib, baseURL)))
}
return out
}
// libraryItemToWireMap reuses the json tags on LibraryItem so handlers
// that need to emit a LibraryItem as part of a heterogeneous map[string]any
// envelope (collections, playlists) don't have to duplicate the camelCase
// key set.
func libraryItemToWireMap(li LibraryItem) map[string]any {
b, _ := json.Marshal(li)
var m map[string]any
_ = json.Unmarshal(b, &m)
return m
}
// handleListLibraryCollections — GET /libraries/{libraryId}/collections.
//
// LazyBookshelf hits this for the "Collections" tab — it expects the
// canonical paged envelope {results, total, limit, page, ...} with each
// entry in full-shape (including books[]) so LazyCollectionCard can
// render the cover stack from the first few books.
//
// silo scopes collections per (user, profile) globally; the libraryId
// URL param is accepted but ignored (matches our playlist behavior).
func (h *Handler) handleListLibraryCollections(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
limit, page := readPagedQuery(r, 25)
if h.deps.CollectionStore == nil {
writeJSON(w, http.StatusOK, pagedEnvelope([]map[string]any{}, 0, limit, page, "name", false, "", false, ""))
return
}
rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs library collection list failed", "err", err, "user", a.UserID)
http.Error(w, "collection list failed", http.StatusInternalServerError)
return
}
total := len(rows)
var pageRows []Collection
if limit == 0 {
pageRows = rows
} else {
start := page * limit
end := start + limit
if start > total {
start = total
}
if end > total {
end = total
}
pageRows = rows[start:end]
}
out := make([]map[string]any, 0, len(pageRows))
for _, c := range pageRows {
out = append(out, h.collectionFullShape(r, c))
}
writeJSON(w, http.StatusOK, pagedEnvelope(out, total, limit, page, "name", false, "", false, ""))
}
// handleListCollections — GET /collections.
// Returns the caller's collections wrapped in {"collections": [...]}.
// List-shape (no books[]).
func (h *Handler) handleListCollections(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"collections": []any{}})
return
}
rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs collection list failed", "err", err, "user", a.UserID)
http.Error(w, "collection list failed", http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(rows))
for _, c := range rows {
out = append(out, collectionToABS(c, nil)) // list-shape: nil books
}
writeJSON(w, http.StatusOK, map[string]any{"collections": out})
}
// chiURLID is a tiny shim around chi.URLParam(r, "id") so handler call
// sites read uniformly. Inlined where unambiguous.
func chiURLID(r *http.Request) string { return chi.URLParam(r, "id") }
// handleGetCollection — GET /collections/{id}.
// Owner gets full-shape; non-owner gets full-shape only when isPublic.
// Otherwise 404 (no existence leak — indistinguishable from real
// not-found).
func (h *Handler) handleGetCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
c, err := h.deps.CollectionStore.GetCollection(r.Context(), chiURLID(r))
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID) && !c.IsPublic) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs collection get failed", "err", err)
http.Error(w, "collection get failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, h.collectionFullShape(r, c))
}
// handleUpdateCollection — PATCH /collections/{id}.
// Owner-only. Partial body: only fields explicitly present are
// modified. Non-owner gets 404 (no leak).
func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
c, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs collection get-for-update failed", "err", err, "id", id)
http.Error(w, "collection get failed", http.StatusInternalServerError)
return
}
var body collectionBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
c.Name = name
}
if body.Description != nil {
c.Description = *body.Description
}
if body.IsPublic != nil {
c.IsPublic = *body.IsPublic
}
if err := h.deps.CollectionStore.UpdateCollection(r.Context(), c); err != nil {
slog.Error("abs collection update failed", "err", err, "id", id)
http.Error(w, "collection persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if err != nil {
slog.Warn("abs collection get-after-update failed", "err", err, "id", id)
persisted = c
}
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
}
// handleAddCollectionBook — POST /collections/{id}/book/{bookId}.
// Owner-gated. Validates the item exists via MediaStore (returns 404
// for unknown items). Idempotent: re-adding is a silent no-op.
// Returns the parent collection's full-shape with updated books[].
func (h *Handler) handleAddCollectionBook(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
bookID := chi.URLParam(r, "bookId")
if bookID == "" {
http.Error(w, "bookId required", http.StatusBadRequest)
return
}
c, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs collection get-for-add failed", "err", err, "id", id)
http.Error(w, "collection get failed", http.StatusInternalServerError)
return
}
// Item validation — avoid orphan refs.
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), bookID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
if err := h.deps.CollectionStore.AddCollectionItem(r.Context(), id, bookID); err != nil {
slog.Error("abs collection add-item failed", "err", err, "id", id, "book", bookID)
http.Error(w, "collection persist failed", http.StatusInternalServerError)
return
}
// Re-fetch to surface updated_at bump.
persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if err != nil {
persisted = c
}
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
}
// handleRemoveCollectionBook — DELETE /collections/{id}/book/{bookId}.
// Owner-gated. Idempotent: removing a non-member is a no-op.
// Returns the parent collection's full-shape with updated books[].
func (h *Handler) handleRemoveCollectionBook(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
bookID := chi.URLParam(r, "bookId")
if bookID == "" {
http.Error(w, "bookId required", http.StatusBadRequest)
return
}
c, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs collection get-for-remove failed", "err", err, "id", id)
http.Error(w, "collection get failed", http.StatusInternalServerError)
return
}
if err := h.deps.CollectionStore.RemoveCollectionItem(r.Context(), id, bookID); err != nil {
slog.Error("abs collection remove-item failed", "err", err, "id", id, "book", bookID)
http.Error(w, "collection delete failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if err != nil {
persisted = c
}
writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted))
}
// handleDeleteCollection — DELETE /collections/{id}.
// Owner-only. Cascade drops user_personal_collection_items via FK CASCADE.
// 204 on success; 404 for unknown or non-owned.
func (h *Handler) handleDeleteCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.CollectionStore == nil {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
c, err := h.deps.CollectionStore.GetCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs collection get-for-delete failed", "err", err, "id", id)
http.Error(w, "collection get failed", http.StatusInternalServerError)
return
}
if err := h.deps.CollectionStore.DeleteCollection(r.Context(), id); err != nil {
slog.Error("abs collection delete failed", "err", err, "id", id)
http.Error(w, "collection delete failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,588 @@
package abs
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// In-memory fakes
// ---------------------------------------------------------------------------
// memCollectionStore is an in-memory CollectionStore for handler tests.
// Owner identity is tracked alongside the row (production stores user_id
// and profile_id; we mirror that so List can filter correctly).
type memCollectionStore struct {
mu sync.Mutex
rows map[string]Collection // id -> row
items map[string][]CollectionItem // collection_id -> items
}
func newMemCollectionStore() *memCollectionStore {
return &memCollectionStore{
rows: map[string]Collection{},
items: map[string][]CollectionItem{},
}
}
func (m *memCollectionStore) ListUserCollections(_ context.Context, userID, profileID string) ([]Collection, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Collection, 0)
for _, c := range m.rows {
if c.UserID == userID && c.ProfileID == profileID {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memCollectionStore) GetCollection(_ context.Context, id string) (Collection, error) {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.rows[id]
if !ok {
return Collection{}, ErrNotFound
}
return c, nil
}
func (m *memCollectionStore) CreateCollection(_ context.Context, c Collection) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[c.ID] = c
return nil
}
func (m *memCollectionStore) UpdateCollection(_ context.Context, c Collection) error {
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.rows[c.ID]
if !ok {
return ErrNotFound
}
existing.Name = c.Name
existing.Description = c.Description
existing.IsPublic = c.IsPublic
existing.UpdatedAt = time.Now()
m.rows[c.ID] = existing
return nil
}
func (m *memCollectionStore) DeleteCollection(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, id)
delete(m.items, id) // cascade
return nil
}
func (m *memCollectionStore) ListCollectionItems(_ context.Context, collectionID string) ([]CollectionItem, error) {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[collectionID]
out := make([]CollectionItem, len(items))
copy(out, items)
sort.Slice(out, func(i, j int) bool { return out[i].AddedAt.Before(out[j].AddedAt) })
return out, nil
}
func (m *memCollectionStore) AddCollectionItem(_ context.Context, collectionID, libraryItemID string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, it := range m.items[collectionID] {
if it.LibraryItemID == libraryItemID {
return nil // ON CONFLICT DO NOTHING
}
}
m.items[collectionID] = append(m.items[collectionID], CollectionItem{
CollectionID: collectionID,
LibraryItemID: libraryItemID,
AddedAt: time.Now(),
})
if c, ok := m.rows[collectionID]; ok {
c.UpdatedAt = time.Now()
m.rows[collectionID] = c
}
return nil
}
func (m *memCollectionStore) RemoveCollectionItem(_ context.Context, collectionID, libraryItemID string) error {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[collectionID]
out := items[:0]
for _, it := range items {
if it.LibraryItemID != libraryItemID {
out = append(out, it)
}
}
m.items[collectionID] = out
if c, ok := m.rows[collectionID]; ok {
c.UpdatedAt = time.Now()
m.rows[collectionID] = c
}
return nil
}
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
type collectionsHarness struct {
H *Handler
Coll *memCollectionStore
Pub *recordingPublisher
}
func newCollectionsHarness(t *testing.T, knownItems ...string) *collectionsHarness {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
pub := &recordingPublisher{}
store := newMemCollectionStore()
h := New(Dependencies{
MediaStore: &stubMediaStore{known: known},
CollectionStore: store,
Publisher: pub,
})
return &collectionsHarness{H: h, Coll: store, Pub: pub}
}
// dispatchABSWithParams drives a handler directly with arbitrary URL
// params + injected ctxAuth, bypassing the bearerAuth middleware.
// Generalised version of dispatchBookmark for surfaces with different
// URL-param shapes (collections use {id}, {bookId}; playlists use
// {id}, {libraryItemId}, {episodeId}).
func dispatchABSWithParams(method, path string, params map[string]string, body []byte, userID, profileID string, fn http.HandlerFunc) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID, ProfileID: profileID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
fn(rec, req)
return rec
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestCollection_Create_ReturnsFullShape(t *testing.T) {
hb := newCollectionsHarness(t)
body := []byte(`{"name":"Favorites","description":"My top picks"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, body, "1", "", hb.H.handleCreateCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if got["name"] != "Favorites" {
t.Errorf("name = %v, want Favorites", got["name"])
}
if got["description"] != "My top picks" {
t.Errorf("description = %v, want 'My top picks'", got["description"])
}
if got["userId"] != "1" {
t.Errorf("userId = %v, want 1", got["userId"])
}
if got["isPublic"] != false {
t.Errorf("isPublic = %v, want false", got["isPublic"])
}
for _, k := range []string{"id", "lastUpdate", "createdAt"} {
if _, ok := got[k]; !ok {
t.Errorf("response missing %q", k)
}
}
books, ok := got["books"].([]any)
if !ok || len(books) != 0 {
t.Errorf("books = %v (type %T), want empty array", got["books"], got["books"])
}
}
func TestCollection_Create_NameRequired_400(t *testing.T) {
hb := newCollectionsHarness(t)
body := []byte(`{"description":"only"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, body, "1", "", hb.H.handleCreateCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_Create_InvalidBody_400(t *testing.T) {
hb := newCollectionsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{not json`), "1", "", hb.H.handleCreateCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_List_ReturnsWrappedEnvelope(t *testing.T) {
hb := newCollectionsHarness(t)
// Seed two collections.
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"A"}`), "1", "", hb.H.handleCreateCollection)
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"B"}`), "1", "", hb.H.handleCreateCollection)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "1", "", hb.H.handleListCollections)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var env map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
list, ok := env["collections"].([]any)
if !ok {
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
}
if len(list) != 2 {
t.Errorf("list len = %d, want 2", len(list))
}
// List-shape must omit books.
for _, c := range list {
entry := c.(map[string]any)
if _, has := entry["books"]; has {
t.Errorf("list entry has books key (should be detail-only): %v", entry)
}
}
}
func TestCollection_List_DoesNotLeakOtherUsers(t *testing.T) {
hb := newCollectionsHarness(t)
// User 1 creates.
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"mine"}`), "1", "", hb.H.handleCreateCollection)
// User 2 lists.
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "2", "", hb.H.handleListCollections)
var env map[string]any
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
list, ok := env["collections"].([]any)
if !ok {
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
}
if len(list) != 0 {
t.Errorf("user 2 sees %d collections, want 0", len(list))
}
}
func TestCollection_List_ProfileIsolation(t *testing.T) {
hb := newCollectionsHarness(t)
pA := "00000000-0000-0000-0000-0000000000aa"
pB := "00000000-0000-0000-0000-0000000000bb"
_ = dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{"name":"A"}`), "1", pA, hb.H.handleCreateCollection)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections", nil, nil, "1", pB, hb.H.handleListCollections)
var env map[string]any
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
list, ok := env["collections"].([]any)
if !ok {
t.Fatalf("response missing 'collections' key; body=%s", rec.Body.String())
}
if len(list) != 0 {
t.Errorf("profile B sees %d collections, want 0", len(list))
}
}
// createCollectionForUser is a tiny helper that POSTs a collection and
// returns its id. Used by tests that need to seed a row.
func createCollectionForUser(t *testing.T, hb *collectionsHarness, userID, profileID, body string) string {
t.Helper()
rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(body), userID, profileID, hb.H.handleCreateCollection)
if rec.Code != http.StatusOK {
t.Fatalf("seed POST status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
id, _ := got["id"].(string)
if id == "" {
t.Fatalf("seed POST returned no id; body=%s", rec.Body.String())
}
return id
}
func TestCollection_Get_Owner_ReturnsFullShape(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "mine" {
t.Errorf("name = %v, want 'mine'", got["name"])
}
books, ok := got["books"].([]any)
if !ok {
t.Errorf("books missing on full-shape response: %v", got)
}
if len(books) != 0 {
t.Errorf("books len = %d, want 0 for freshly created", len(books))
}
}
func TestCollection_Get_NonOwner_Private_404(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"private"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("non-owner private GET status = %d, want 404 (anti-enumeration); body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_Get_NonOwner_Public_OK(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetCollection)
if rec.Code != http.StatusOK {
t.Fatalf("non-owner public GET status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "public" {
t.Errorf("name = %v, want 'public'", got["name"])
}
}
func TestCollection_Get_Unknown_404(t *testing.T) {
hb := newCollectionsHarness(t)
rec := dispatchABSWithParams(http.MethodGet, "/api/collections/01HZZZ", map[string]string{"id": "01HZZZ"}, nil, "1", "", hb.H.handleGetCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_Patch_OwnerUpdatesNameAndDescription(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"old","description":"d1"}`)
body := []byte(`{"name":"new","description":"d2","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/collections/"+id, map[string]string{"id": id}, body, "1", "", hb.H.handleUpdateCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "new" {
t.Errorf("name = %v, want 'new'", got["name"])
}
if got["description"] != "d2" {
t.Errorf("description = %v, want 'd2'", got["description"])
}
if got["isPublic"] != true {
t.Errorf("isPublic = %v, want true", got["isPublic"])
}
}
func TestCollection_Patch_PartialOnlyChangesPresentFields(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"keep","description":"d1"}`)
// PATCH only name; description and isPublic must stay.
body := []byte(`{"name":"renamed"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/collections/"+id, map[string]string{"id": id}, body, "1", "", hb.H.handleUpdateCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "renamed" {
t.Errorf("name = %v, want 'renamed'", got["name"])
}
if got["description"] != "d1" {
t.Errorf("description = %v, want 'd1' (unchanged)", got["description"])
}
}
func TestCollection_Patch_NonOwner_404(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
body := []byte(`{"name":"hijack"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/collections/"+id, map[string]string{"id": id}, body, "2", "", hb.H.handleUpdateCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (no leak); body=%s", rec.Code, rec.Body.String())
}
// User 1's collection must be untouched.
c, _ := hb.Coll.GetCollection(context.Background(), id)
if c.Name != "mine" {
t.Errorf("collection name = %q, want 'mine'; non-owner mutation leaked", c.Name)
}
}
func TestCollection_Delete_Owner_204(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
// Seed an item so the cascade-delete is exercised.
_ = dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleAddCollectionBook)
rec := dispatchABSWithParams(http.MethodDelete, "/api/collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleDeleteCollection)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
// Subsequent GET must 404.
rec2 := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetCollection)
if rec2.Code != http.StatusNotFound {
t.Errorf("post-delete GET status = %d, want 404", rec2.Code)
}
// Cascade: items table must be empty for the deleted collection.
items, _ := hb.Coll.ListCollectionItems(context.Background(), id)
if len(items) != 0 {
t.Errorf("items len = %d, want 0 (cascade did not drop child rows)", len(items))
}
}
func TestCollection_Delete_NonOwner_404(t *testing.T) {
hb := newCollectionsHarness(t)
id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodDelete, "/api/collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleDeleteCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
// User 1's collection still exists.
if _, err := hb.Coll.GetCollection(context.Background(), id); err != nil {
t.Errorf("collection wrongly deleted: %v", err)
}
}
func TestCollection_AddBook_Owner_HydratesInResponse(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleAddCollectionBook)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
books, _ := got["books"].([]any)
if len(books) != 1 {
t.Fatalf("books len = %d, want 1", len(books))
}
entry := books[0].(map[string]any)
if entry["id"] != "book-1" {
t.Errorf("book entry id = %v, want book-1", entry["id"])
}
if _, has := entry["media"]; !has {
t.Errorf("book entry missing media hydration: %v", entry)
}
}
func TestCollection_AddBook_Idempotent(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleAddCollectionBook)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleAddCollectionBook)
if rec.Code != http.StatusOK {
t.Fatalf("second add status = %d, want 200 (idempotent); body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
books, _ := got["books"].([]any)
if len(books) != 1 {
t.Errorf("books len after double-add = %d, want 1", len(books))
}
}
func TestCollection_AddBook_UnknownItem_404(t *testing.T) {
hb := newCollectionsHarness(t /* no known items */)
id := createCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/ghost",
map[string]string{"id": id, "bookId": "ghost"}, nil, "1", "", hb.H.handleAddCollectionBook)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (item not found); body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_AddBook_NonOwner_404(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "2", "", hb.H.handleAddCollectionBook)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (no leak); body=%s", rec.Code, rec.Body.String())
}
}
func TestCollection_RemoveBook_Idempotent(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
// Remove book that was never added — should be 200 with empty books.
rec := dispatchABSWithParams(http.MethodDelete, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleRemoveCollectionBook)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
books, _ := got["books"].([]any)
if len(books) != 0 {
t.Errorf("books len = %d, want 0", len(books))
}
}
func TestCollection_RemoveBook_NonOwner_404(t *testing.T) {
hb := newCollectionsHarness(t, "book-1")
id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "1", "", hb.H.handleAddCollectionBook)
rec := dispatchABSWithParams(http.MethodDelete, "/api/collections/"+id+"/book/book-1",
map[string]string{"id": id, "bookId": "book-1"}, nil, "2", "", hb.H.handleRemoveCollectionBook)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
// User 1's items must be intact.
items, _ := hb.Coll.ListCollectionItems(context.Background(), id)
if len(items) != 1 {
t.Errorf("items len = %d, want 1 (non-owner remove leaked)", len(items))
}
}
@@ -0,0 +1,54 @@
package abs
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
)
func (h *Handler) handleRemoveFromContinueListening(w http.ResponseWriter, r *http.Request) {
h.setHideFromContinue(w, r, true)
}
func (h *Handler) handleReaddToContinueListening(w http.ResponseWriter, r *http.Request) {
h.setHideFromContinue(w, r, false)
}
func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hide bool) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
itemID := chi.URLParam(r, "itemId")
if itemID == "" {
http.Error(w, "itemId required", http.StatusBadRequest)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
if err != nil {
slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID)
http.Error(w, "item lookup failed", http.StatusInternalServerError)
return
}
if item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
if h.deps.ProgressStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
if err := h.deps.ProgressStore.SetHideFromContinue(r.Context(), a.UserID, a.ProfileID, itemID, hide); err != nil {
slog.Error("abs continue toggle failed", "err", err, "user", a.UserID, "item", itemID, "hide", hide)
http.Error(w, "continue toggle failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -0,0 +1,91 @@
package abs
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type recordingProgressFake struct {
fakeProgressStore
mu sync.Mutex
last string
}
func (f *recordingProgressFake) SetHideFromContinue(_ context.Context, userID, profileID, contentID string, hide bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if hide {
f.last = "hide:" + contentID
} else {
f.last = "show:" + contentID
}
return nil
}
func TestContinue_Remove_SetsHide(t *testing.T) {
prog := &recordingProgressFake{}
media := &stubMediaStore{known: map[string]*models.MediaItem{"book-1": nil}}
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening",
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["ok"] != true {
t.Errorf("ok = %v", got["ok"])
}
if prog.last != "hide:book-1" {
t.Errorf("last = %q, want hide:book-1", prog.last)
}
}
func TestContinue_Readd_SetsShow(t *testing.T) {
prog := &recordingProgressFake{}
media := &stubMediaStore{known: map[string]*models.MediaItem{"book-1": nil}}
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/readd-to-continue-listening",
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleReaddToContinueListening)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if prog.last != "show:book-1" {
t.Errorf("last = %q, want show:book-1", prog.last)
}
}
func TestContinue_UnknownItem_404(t *testing.T) {
prog := &recordingProgressFake{}
media := &stubMediaStore{known: map[string]*models.MediaItem{}}
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/ghost/remove-from-continue-listening",
map[string]string{"itemId": "ghost"}, nil, "1", "", h.handleRemoveFromContinueListening)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestContinue_MediaLookupError_500(t *testing.T) {
prog := &recordingProgressFake{}
media := &stubMediaStore{
known: map[string]*models.MediaItem{"book-1": nil},
lookupErr: errors.New("media lookup failed"),
}
h := New(Dependencies{MediaStore: media, ProgressStore: prog})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/progress/book-1/remove-from-continue-listening",
map[string]string{"itemId": "book-1"}, nil, "1", "", h.handleRemoveFromContinueListening)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
}
}
+255
View File
@@ -0,0 +1,255 @@
package abs
import (
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
// extras_handlers.go bundles the ABS endpoints that don't fit naturally
// into the existing per-domain handler files: server discovery (ping /
// healthcheck / init), year-in-review stats, the ebook / e-reader / email
// surface (stub responses until the scanner extends), and the podcast
// endpoints (also stubs — silo's catalog is audiobook-only in v1).
//
// Each handler emits a shape compatible with the official audiobookshelf
// clients (AudioBooth, audiobookshelf-app) so a request never explodes the
// client. Stub endpoints return the well-formed "empty" / "unavailable"
// shape rather than 404/500: clients have been observed to render error
// dialogs on hard failures but to silently degrade on empty arrays.
// ---------------------------------------------------------------------------
// Server discovery
// ---------------------------------------------------------------------------
// handlePing — GET /ping
// Standard ABS heartbeat. The canonical server returns {"success": true};
// AudioBooth uses this for liveness checks on the saved server before
// attempting auth.
func (h *Handler) handlePing(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
// handleHealthcheck — GET /healthcheck
// Alias of /ping; some deployments hit this from k8s/docker probes.
func (h *Handler) handleHealthcheck(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// handleInit — GET /init
// Returns the bootstrap payload ABS clients read to decide whether the
// server needs first-run setup. silo is always "initialized" (there's no
// install wizard); we surface that so clients skip straight to login.
func (h *Handler) handleInit(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"isInit": true,
"language": "en-us",
"authMethods": []string{"local"},
"authFormData": map[string]any{},
"serverSettings": map[string]any{},
})
}
// ---------------------------------------------------------------------------
// Auth-settings — clients fetch this to enumerate available providers
// ---------------------------------------------------------------------------
// handleAuthSettings — GET /auth-settings
// AudioBooth queries this on the "Add server" screen to know whether OIDC
// is enabled. silo is local-auth-only today.
func (h *Handler) handleAuthSettings(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"authActiveAuthMethods": []string{"local"},
"authOpenIDIssuerURL": nil,
"authOpenIDAuthorizationURL": nil,
"authPasswordlessSettings": map[string]any{},
})
}
// ---------------------------------------------------------------------------
// Year-in-review stats — /me/stats/year/{year}
// ---------------------------------------------------------------------------
// handleYearStats — GET /me/stats/year/{year}
// AudioBooth's `fetchYearStats(year:)` decodes a YearStats struct with 13
// keys (totals + topAuthors / topGenres / mostListenedNarrator /
// mostListenedMonth / numBooksFinished / numBooksListened /
// longestAudiobookFinished / booksWithCovers / finishedBooksWithCovers).
//
// We synthesize the shape from AggregateStats (no per-year rollup table
// yet — that lands when listening_history has at least a year of data).
// Empty arrays are emitted with the JSON key present so the Swift decoder
// doesn't choke on missing fields.
func (h *Handler) handleYearStats(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Year param accepted but currently unused — when listening_history
// reaches multi-year scale we'll filter AggregateStats by year here.
_, _ = strconv.Atoi(chi.URLParam(r, "year"))
totalSeconds := 0
totalSessions := 0
if h.deps.PlaybackSessionStore != nil {
if stats, err := h.deps.PlaybackSessionStore.AggregateStats(r.Context(), a.UserID, a.ProfileID); err == nil {
totalSeconds = stats.TotalTime
totalSessions = stats.Items
}
}
writeJSON(w, http.StatusOK, map[string]any{
"totalListeningSessions": totalSessions,
"totalListeningTime": float64(totalSeconds),
"totalBookListeningTime": float64(totalSeconds),
"totalPodcastListeningTime": 0.0,
"topAuthors": []any{},
"topGenres": []any{},
"mostListenedNarrator": nil,
"mostListenedMonth": nil,
"numBooksFinished": 0,
"numBooksListened": totalSessions,
"longestAudiobookFinished": nil,
"booksWithCovers": []string{},
"finishedBooksWithCovers": []string{},
})
}
// ---------------------------------------------------------------------------
// Progress — DELETE and episode-progress stub
// ---------------------------------------------------------------------------
// handleDeleteItemProgress — DELETE /me/progress/{libraryItemId}
// Backs the ABS "Reset Progress" action. Idempotent.
func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.ProgressStore == nil {
w.WriteHeader(http.StatusNoContent)
return
}
contentID := chi.URLParam(r, "libraryItemId")
if err := h.deps.ProgressStore.DeleteProgress(r.Context(), a.UserID, a.ProfileID, contentID); err != nil {
slog.Warn("abs delete progress failed", "err", err, "content", contentID)
http.Error(w, "delete progress failed", http.StatusInternalServerError)
return
}
h.publish(a.UserID, "user_item_progress_updated", map[string]any{
"data": map[string]any{"libraryItemId": contentID, "currentTime": 0, "isFinished": false, "progress": 0},
})
w.WriteHeader(http.StatusNoContent)
}
// handleSetEpisodeProgress — PATCH /me/progress/{libraryItemId}/{episodeId}
// Podcast episode progress. silo's catalog is audiobook-only in v1; this
// returns the empty progress shape so the client can store offline state
// without raising an error.
func (h *Handler) handleSetEpisodeProgress(w http.ResponseWriter, r *http.Request) {
if a, ok := absAuthFrom(r); !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"libraryItemId": chi.URLParam(r, "libraryItemId"),
"episodeId": chi.URLParam(r, "episodeId"),
"currentTime": 0.0,
"duration": 0.0,
"isFinished": false,
"progress": 0.0,
"lastUpdate": 0,
})
}
// ---------------------------------------------------------------------------
// Ebooks — stub surface until ebook scanner lands
// ---------------------------------------------------------------------------
// handleEbookFile — GET /items/{id}/ebook/{fileid}
// Streams an ebook file (epub / pdf / mobi / cbz). silo's audiobook
// scanner does not yet enumerate ebook files; until it does this returns
// 404. The shape was intentionally chosen over 501 because the ABS web
// reader treats 404 as "no ebook available for this item" and degrades
// cleanly; 501 surfaces an alarming error banner.
func (h *Handler) handleEbookFile(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "ebook not available", http.StatusNotFound)
}
// handleEbookStatus — PATCH /items/{id}/ebook/{fileid}/status
// Marks an ebook file as read/unread. silo has no ebook catalog yet;
// accept the request and return the empty status object so the client
// optimistic update succeeds.
func (h *Handler) handleEbookStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"libraryItemId": chi.URLParam(r, "id"),
"fileId": chi.URLParam(r, "fileid"),
"isSupplementary": false,
})
}
// ---------------------------------------------------------------------------
// E-reader devices + ebook email delivery — stub
// ---------------------------------------------------------------------------
// handleListEreaderDevices — GET /me/ereader-devices
// Returns an empty list — silo has no email infrastructure wired yet.
// The official client UI hides the "Send to e-reader" CTA when the list
// is empty, which is the desired state today.
func (h *Handler) handleListEreaderDevices(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ereaderDevices": []any{}})
}
// handleSendEbookToDevice — POST /emails/send-ebook-to-device
// silo has no SMTP/email integration; surface 503 so the mobile UI can
// show a clear "Email delivery not configured" toast rather than a stuck
// spinner.
func (h *Handler) handleSendEbookToDevice(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "email delivery not configured", http.StatusServiceUnavailable)
}
// ---------------------------------------------------------------------------
// Podcast endpoints — stubs (audiobook-only catalog in v1)
// ---------------------------------------------------------------------------
// handlePodcastFeed — POST /podcasts/feed
// Validates an RSS feed URL and returns the parsed podcast metadata so
// the user can preview before subscribing. silo has no podcast subsystem;
// return an empty preview object so the client renders an "unknown feed"
// state and the user can back out without an error toast.
func (h *Handler) handlePodcastFeed(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"podcast": map[string]any{
"metadata": map[string]any{"title": "", "author": "", "description": "", "feedUrl": "", "language": ""},
"episodes": []any{},
}})
}
// handlePlayEpisode — POST /items/{id}/play/{episodeId}
// Episode-scoped play-session start. Audiobook-only catalog can't
// resolve an episodeId, so 404 keeps the client behavior unambiguous.
func (h *Handler) handlePlayEpisode(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "episode not found", http.StatusNotFound)
}
// handleRecentEpisodes — GET /libraries/{id}/recent-episodes
// Paged list of the newest podcast episodes across the library. silo has
// no episodes; emit the canonical paged-envelope so the home shelf
// renders as "no recent episodes".
func (h *Handler) handleRecentEpisodes(w http.ResponseWriter, r *http.Request) {
limit, page := readPagedQuery(r, 25)
writeJSON(w, http.StatusOK, pagedEnvelope(
[]map[string]any{}, 0, limit, page, "publishedAt", true, "", false, "",
))
}
// handleSearchPodcast — GET /search/podcast
// Podcast directory discovery. silo doesn't proxy iTunes/PodcastIndex
// today; return an empty results array so the client search UI shows
// "no results" cleanly.
func (h *Handler) handleSearchPodcast(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, []any{})
}
+228
View File
@@ -0,0 +1,228 @@
package abs
import (
"crypto/md5"
"encoding/hex"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/playback"
)
const publicTrackSessionTTL = 24 * time.Hour
// trackInoFor derives a stable, ABS-compatible inode string from a content ID
// and a 0-based file index. Real ABS uses the filesystem inode (a large
// positive BigInt-shaped string); we hash to a 12-hex-digit prefix and parse
// it as a decimal so the client sees an identifier of the same shape.
//
// Stability matters: the mobile app keys offline downloads by ino. This
// implementation must remain bit-for-bit identical to the ino that handlePlayStart
// embeds in the track list, otherwise file lookups will fail.
func trackInoFor(contentID string, fileIdx int) string {
sum := md5.Sum([]byte(contentID + "/" + strconv.Itoa(fileIdx)))
hexStr := hex.EncodeToString(sum[:6])
n, _ := strconv.ParseUint(hexStr, 16, 64)
return strconv.FormatUint(n, 10)
}
// handleFileStream serves the audio bytes for one file of an audiobook library
// item. Real ABS uses /api/items/{id}/file/{ino}/download for offline-save
// and /api/items/{id}/file/{ino}?token=<jwt> for iOS streaming; both URL
// patterns share this handler.
//
// Auth is via the bearerAuth middleware, which accepts the Authorization header
// and a ?token= query-param fallback (the iOS streaming variant uses ?token=
// because AVPlayer doesn't add Authorization on its own subrequests).
//
// "ino" in real ABS is the file's filesystem inode. We synthesise an
// MD5-derived inode-shaped string per trackInoFor — the same value emitted by
// handlePlayStart. To reverse: call GetMediaFiles for the item, then find the
// file whose index matches the ino. As a fallback we also accept a bare
// 0-based integer index.
//
// Behaviour:
// - Validate ABS bearer token (bearerAuth middleware has already done this).
// - Look up the requested file in silo's media_files table.
// - Serve the bytes directly with Range-request support via playback.ServeDirectPlay.
// - Set Content-Disposition: attachment on /download paths to encourage
// browser save-to-disk / mobile offline-save behaviour.
func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "libraryItemId")
inoStr := chi.URLParam(r, "ino")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID, access)
if err != nil || len(files) == 0 {
http.Error(w, "item not found", http.StatusNotFound)
return
}
// Resolve the ino back to a file by recomputing trackInoFor for each file
// at its 0-based position in the sorted slice. Keeping the resolution logic
// here (symmetric with handlePlayStart's generation) means both paths stay
// in sync whenever the sort order or ino derivation changes.
fileIdx := -1
for i, f := range files {
if trackInoFor(contentID, i) == inoStr {
fileIdx = i
_ = f
break
}
}
// Fallback: legacy or third-party callers sometimes pass the raw 0-based
// file index directly. Accept it when it resolves to a real position.
if fileIdx < 0 {
if n, err := strconv.Atoi(inoStr); err == nil && n >= 0 && n < len(files) {
fileIdx = n
}
}
if fileIdx < 0 {
http.Error(w, "file not found", http.StatusNotFound)
return
}
mediaFile := files[fileIdx]
// /download variant: hint the client to save rather than stream.
if strings.HasSuffix(r.URL.Path, "/download") {
filename := filepath.Base(mediaFile.FilePath)
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
}
// Set Content-Type for audio files. ServeDirectPlay uses MimeFromExtension
// which covers video containers; we override with audio-specific MIME
// types because ABS clients pattern-match on Content-Type.
ext := strings.ToLower(filepath.Ext(mediaFile.FilePath))
if ct := audioContentType(ext); ct != "" {
w.Header().Set("Content-Type", ct)
}
if err := playback.ServeDirectPlay(w, r, mediaFile.FilePath); err != nil {
// ServeDirectPlay has already written an error response; just log.
return
}
}
// handlePublicTrack serves audio bytes for ONE track of a playback session.
//
// Real ABS Android client (v2.22.0+) builds the streaming URL as
//
// $serverAddress/public/session/{sessionId}/track/{audioTrack.index}
//
// WITHOUT appending any ?token=. The session ID itself is the capability:
// it's a 128-bit ULID, only known to the client that received it from
// /play, and tied server-side to (userID, contentID). This matches both
// the canonical continuum-plugin handler and booklore-ng's implementation.
//
// See android: PlaybackSession.kt:getContentUri (gte 2.22.0 + DirectPlay branch).
//
// Resolution:
// 1. Look up the session by sid via PlaybackSessionStore.
// 2. Load the ordered media-files list for the session's contentID.
// 3. files[idx-1] is the requested track (silo emits 1-based wireIndex).
// 4. Stream via playback.ServeDirectPlay (handles Range + HEAD).
//
// Mounted OUTSIDE bearerAuth: the client sends no Authorization header on
// this endpoint, and the session ID alone authorises access.
func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
sid := chi.URLParam(r, "sid")
idxStr := chi.URLParam(r, "idx")
if sid == "" || idxStr == "" {
http.Error(w, "sid and idx required", http.StatusBadRequest)
return
}
idx, err := strconv.Atoi(idxStr)
if err != nil || idx < 1 {
http.Error(w, "idx must be a positive integer", http.StatusBadRequest)
return
}
if h.deps.PlaybackSessionStore == nil {
http.Error(w, "session store not configured", http.StatusServiceUnavailable)
return
}
sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid)
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if sess.ClosedAt != nil {
http.Error(w, "session closed", http.StatusGone)
return
}
if publicTrackSessionExpired(sess, time.Now()) {
http.Error(w, "session expired", http.StatusGone)
return
}
access, err := h.accessFilterForAuth(r.Context(), ctxAuth{UserID: sess.UserID, ProfileID: sess.ProfileID})
if err != nil {
http.Error(w, "session access denied", http.StatusForbidden)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), sess.ContentID, access)
if err != nil || len(files) == 0 {
http.Error(w, "item files not found", http.StatusNotFound)
return
}
if idx > len(files) {
http.Error(w, "track index out of range", http.StatusNotFound)
return
}
mediaFile := files[idx-1]
ext := strings.ToLower(filepath.Ext(mediaFile.FilePath))
if ct := audioContentType(ext); ct != "" {
w.Header().Set("Content-Type", ct)
}
_ = playback.ServeDirectPlay(w, r, mediaFile.FilePath)
}
func publicTrackSessionExpired(sess ABSPlaybackSession, now time.Time) bool {
anchor := sess.StartedAt
if sess.LastSyncAt.After(anchor) {
anchor = sess.LastSyncAt
}
return !anchor.IsZero() && now.After(anchor.Add(publicTrackSessionTTL))
}
// audioContentType returns an audio MIME type for the given file extension
// (including the dot). Returns empty string for unknown extensions, letting
// ServeDirectPlay fall back to its own MIME detection.
func audioContentType(ext string) string {
switch ext {
case ".mp3":
return "audio/mpeg"
case ".m4b", ".m4a":
return "audio/mp4"
case ".flac":
return "audio/flac"
case ".ogg":
return "audio/ogg"
case ".opus":
return "audio/opus"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
}
return ""
}
@@ -0,0 +1,183 @@
package abs
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakePlaybackSessionStore is an in-memory ABSPlaybackSessionStore for the
// public-track tests. Only Get is exercised; the other methods are no-ops.
type fakePlaybackSessionStore struct {
sessions map[string]ABSPlaybackSession
}
func (f *fakePlaybackSessionStore) InsertPlaybackSession(_ context.Context, s ABSPlaybackSession) error {
if f.sessions == nil {
f.sessions = map[string]ABSPlaybackSession{}
}
f.sessions[s.ID] = s
return nil
}
func (f *fakePlaybackSessionStore) GetPlaybackSession(_ context.Context, id string) (ABSPlaybackSession, error) {
s, ok := f.sessions[id]
if !ok {
return ABSPlaybackSession{}, ErrNotFound
}
return s, nil
}
func (f *fakePlaybackSessionStore) SyncPlaybackSession(context.Context, string, float64, int) error {
return nil
}
func (f *fakePlaybackSessionStore) ClosePlaybackSession(context.Context, string) error { return nil }
func (f *fakePlaybackSessionStore) CloseOpenSessionsForPrincipal(context.Context, string, string) error {
return nil
}
func (f *fakePlaybackSessionStore) AggregateStats(_ context.Context, userID, profileID string) (Stats, error) {
return Stats{Days: []DayStat{}, Monthly: []MonthStat{}}, nil
}
func (f *fakePlaybackSessionStore) ListClosedSessions(_ context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error) {
return nil, 0, nil
}
// filesMediaStore returns a fixed slice of MediaFile entries for the
// configured contentID, satisfying the MediaStore interface for the
// public-track tests. Unconfigured methods inherit no-op behavior from
// noopMediaStore via embedding.
type filesMediaStore struct {
noopMediaStore
contentID string
files []*models.MediaFile
}
func (f *filesMediaStore) GetMediaFiles(_ context.Context, contentID string, _ catalog.AccessFilter) ([]*models.MediaFile, error) {
if contentID != f.contentID {
return nil, nil
}
return f.files, nil
}
// makeTempAudio writes minimal bytes to a .mp3 file in t.TempDir() and
// returns the path. ServeDirectPlay only needs the file to exist and be
// readable; content correctness is not asserted by these tests.
func makeTempAudio(t *testing.T) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "track.mp3")
if err := os.WriteFile(p, []byte("\xff\xfb\x00\x00audio-bytes"), 0o644); err != nil {
t.Fatalf("write temp audio: %v", err)
}
return p
}
// newPublicTrackHandler builds a Handler with the minimum deps to serve
// /public/session/{sid}/track/{idx}: a seeded session store + a media store
// holding ONE audio file for that session's contentID.
func newPublicTrackHandler(t *testing.T, sid, contentID string, closed bool) (*Handler, string) {
t.Helper()
audioPath := makeTempAudio(t)
sessStore := &fakePlaybackSessionStore{}
sess := ABSPlaybackSession{ID: sid, UserID: "u1", ContentID: contentID}
if closed {
now := time.Now()
sess.ClosedAt = &now
}
_ = sessStore.InsertPlaybackSession(context.Background(), sess)
mediaStore := &filesMediaStore{
contentID: contentID,
files: []*models.MediaFile{{ID: 1, FilePath: audioPath}},
}
h := New(Dependencies{
MediaStore: mediaStore,
PlaybackSessionStore: sessStore,
})
return h, audioPath
}
// dispatchTrack invokes handlePublicTrack with the URL params chi would
// normally inject from the route. Mirrors how chi.URLParam reads from the
// request context — without this the handler can't see {sid}/{idx}.
func dispatchTrack(h *Handler, method, sid, idx string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/public/session/"+sid+"/track/"+idx, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("sid", sid)
rctx.URLParams.Add("idx", idx)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
h.handlePublicTrack(rec, req)
return rec
}
func TestHandlePublicTrack_ServesBytesForValidSession(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false)
rec := dispatchTrack(h, http.MethodGet, "sid-1", "1")
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if rec.Body.Len() == 0 {
t.Errorf("response body empty; expected audio bytes")
}
if got := rec.Header().Get("Content-Type"); got != "audio/mpeg" {
t.Errorf("Content-Type = %q, want audio/mpeg", got)
}
}
// TestHandlePublicTrack_HeadProbe covers the iOS/Android HEAD pre-flight
// some players issue before the GET. http.ServeContent returns headers
// without a body for HEAD; the handler must not 404.
func TestHandlePublicTrack_HeadProbe(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false)
rec := dispatchTrack(h, http.MethodHead, "sid-1", "1")
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD response should have empty body; got %d bytes", rec.Body.Len())
}
}
func TestHandlePublicTrack_UnknownSession404(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false)
rec := dispatchTrack(h, http.MethodGet, "sid-does-not-exist", "1")
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestHandlePublicTrack_ClosedSession410(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", true)
rec := dispatchTrack(h, http.MethodGet, "sid-1", "1")
if rec.Code != http.StatusGone {
t.Errorf("status = %d, want 410", rec.Code)
}
}
func TestHandlePublicTrack_IndexOutOfRange404(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false)
rec := dispatchTrack(h, http.MethodGet, "sid-1", "5")
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestHandlePublicTrack_BadIndex400(t *testing.T) {
h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false)
for _, bad := range []string{"0", "-1", "abc"} {
rec := dispatchTrack(h, http.MethodGet, "sid-1", bad)
if rec.Code != http.StatusBadRequest {
t.Errorf("idx=%q: status = %d, want 400", bad, rec.Code)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package abs
import (
"encoding/base64"
"strings"
)
// FilterKind is the leading segment of an ABS `filter=` query value.
type FilterKind string
const (
FilterAuthors FilterKind = "authors"
FilterSeries FilterKind = "series"
FilterNarrators FilterKind = "narrators"
FilterGenres FilterKind = "genres"
FilterProgress FilterKind = "progress"
FilterTags FilterKind = "tags"
FilterLanguages FilterKind = "languages"
)
// SentinelNoSeries is the literal value real ABS clients send for "books
// without a series" — it is NOT base64-encoded, in contrast to ordinary
// series IDs which are.
const SentinelNoSeries = "no-series"
// Filter describes a parsed ABS `filter=<kind>.<value>` query parameter.
// Value is the post-decode value (base64-decoded for most kinds; sentinel
// values such as "no-series" are passed through). Raw preserves the
// original `<kind>.<value>` for echoing back in pagination envelopes.
type Filter struct {
Kind FilterKind
Value string
Raw string
}
// ParseFilter pulls apart an ABS `filter=` query value. Real ABS encodes the
// value as base64-then-URL-encoded — chi/http already URL-decodes the query,
// so the input we see is `<kind>.<base64-value>`. Two non-encoded special
// cases: the literal `no-series` sentinel and the `progress.*` family
// (in-progress / finished / not-finished). When the value isn't valid
// base64, we treat it as a sentinel and pass it through unchanged.
//
// Returns (Filter{}, false) when raw is empty or has no kind prefix.
func ParseFilter(raw string) (Filter, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Filter{}, false
}
dot := strings.IndexByte(raw, '.')
if dot <= 0 || dot >= len(raw)-1 {
return Filter{}, false
}
kind := FilterKind(raw[:dot])
rest := raw[dot+1:]
out := Filter{Kind: kind, Raw: raw}
// progress.* and the no-series sentinel are never base64-encoded by
// real ABS clients.
if kind == FilterProgress || rest == SentinelNoSeries {
out.Value = rest
return out, true
}
if b, err := base64.RawURLEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
if b, err := base64.RawStdEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
if b, err := base64.StdEncoding.DecodeString(rest); err == nil && len(b) > 0 {
out.Value = string(b)
return out, true
}
// Fall through — accept the raw string as a sentinel. Future ABS
// versions may add more sentinels (mirroring how no-series escaped the
// base64 encoding); this keeps us forward-compatible.
out.Value = rest
return out, true
}
// Matches reports whether the given LibraryItem satisfies this filter. genres
// is best-effort — backend summaries don't carry genres, so a genres filter
// matches nothing unless the caller pre-populated the field via detail
// lookups (we currently don't). tags / languages behave the same way and
// are accepted for forward-compat but always return false.
//
// Progress filters require the caller to supply an optional `inProgress`
// and `finished` flag for the book; without them the progress branch
// returns false. The caller is responsible for joining progress state from
// the store.
func (f Filter) Matches(item LibraryItem, inProgress, finished bool, hasProgress bool) bool {
switch f.Kind {
case FilterAuthors:
for _, a := range item.Media.Metadata.Authors {
if a.ID == f.Value || a.Name == f.Value {
return true
}
}
return false
case FilterSeries:
if f.Value == SentinelNoSeries {
return len(item.Media.Metadata.Series) == 0
}
for _, s := range item.Media.Metadata.Series {
if s.ID == f.Value || s.Name == f.Value {
return true
}
}
return false
case FilterNarrators:
for _, n := range item.Media.Metadata.Narrators {
if n == f.Value {
return true
}
}
return false
case FilterProgress:
switch f.Value {
case "in-progress":
return hasProgress && inProgress && !finished
case "finished":
return hasProgress && finished
case "not-finished":
return !finished
case "not-started":
return !hasProgress
}
return false
default:
// genres / tags / languages — not derivable from a summary today.
return false
}
}
+750
View File
@@ -0,0 +1,750 @@
// Package abs implements the Audiobookshelf-mobile-app compatibility surface.
// It mints self-contained JWTs signed with a per-deployment secret and serves
// the /abs/api/* and /abs/public/* routes, as well as the canonical
// root-level paths real ABS clients build against (e.g. /login, /api/items).
//
// Stage 1 lands the package skeleton: Handler struct, interface stubs for
// silo-side dependencies, and an empty Mount() method. Real route handlers
// are added in subsequent stages (auth, file serving, progress, browse).
package abs
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// Dependency interfaces
// ---------------------------------------------------------------------------
// AudiobookLibrary is the narrow library view the ABS handlers expose.
// The production adapter (Stage 7) builds these from media_folders WHERE
// type = 'audiobooks'.
type AudiobookLibrary struct {
ID int64
Name string
Type string // always "audiobooks" for this surface
}
// MediaStore is the slice of silo's catalog the ABS handler reads.
// Real impl: catalog.ItemRepository + scanner.FileRepository wrapped in
// a small adapter struct added in a later stage.
type MediaStore interface {
GetAudiobookByID(ctx context.Context, contentID string, access catalog.AccessFilter) (*models.MediaItem, error)
// ListAudiobooks returns a page of audiobooks. When libraryID is non-zero
// it filters to items in that media_folder; 0 means all audiobook items.
ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter) ([]*models.MediaItem, int, error)
GetMediaFiles(ctx context.Context, contentID string, access catalog.AccessFilter) ([]*models.MediaFile, error)
// GetMediaFileByID fetches a single media file by its integer PK.
// Used by the ABS file-streaming handler when a caller supplies a
// raw file ID instead of an ino.
GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error)
// ListAudiobookLibraries returns media_folder rows with type='audiobooks'.
ListAudiobookLibraries(ctx context.Context, access catalog.AccessFilter) ([]AudiobookLibrary, error)
// SearchAudiobooks does a fuzzy title/author/narrator match for the ABS
// /libraries/{id}/search endpoint. Hydrates People so the mapper has
// author/narrator names.
SearchAudiobooks(ctx context.Context, libraryID int64, query string, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListContinueListening returns books that the given user has progress
// on but hasn't finished — feeds the Home tab's continue shelf.
ListContinueListening(ctx context.Context, userID, profileID string, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListRecentlyAdded returns the most recently added audiobooks for the
// Home tab's recently-added shelf.
ListRecentlyAdded(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListDiscover returns a randomized sampling of audiobooks for the
// Home tab's discover shelf (helps new users browse the library).
ListDiscover(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListLibraryAuthors returns distinct authors of audiobooks in the
// library along with each author's book count.
ListLibraryAuthors(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]AuthorSummary, error)
// ListLibrarySeries returns distinct series (from audiobook_series)
// represented in the library, ordered by name.
ListLibrarySeries(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]SeriesSummary, error)
// GetAuthorByID returns the author with the given people.id plus
// their audiobook list, sorted by title. Returns ErrNotFound when
// no people row matches.
GetAuthorByID(ctx context.Context, authorID string, access catalog.AccessFilter) (Author, error)
// GetSeriesByName returns the canonical series (case-insensitive
// match on audiobook_series.series_name) with its books ordered
// by series_index ASC (NULLS LAST), title fallback. Returns
// ErrNotFound when no rows match.
GetSeriesByName(ctx context.Context, seriesName string, access catalog.AccessFilter) (Series, error)
}
// AuthorSummary is an aggregated author entry for /libraries/{id}/authors.
type AuthorSummary struct {
ID string
Name string
NumBooks int
}
// SeriesSummary is an aggregated series entry for /libraries/{id}/series.
//
// Books carries up to ~4 cover-preview entries for the LazySeriesCard
// GroupCover stack — the ABS mobile client reads
// `series.books[i].media.coverPath` to render each cover, and a card
// with no books renders only the series name as a fallback.
type SeriesSummary struct {
ID string
Name string
NumBooks int
Books []SeriesBookPreview
}
// SeriesBookPreview is a single book id+title pair returned alongside
// each SeriesSummary. The /libraries/{id}/series handler expands these
// into full minified LibraryItem entries (with cover URLs) on the wire.
type SeriesBookPreview struct {
ContentID string
Title string
UpdatedAt time.Time
}
// Author is the detail-shape author with embedded books list.
type Author struct {
ID string
Name string
PosterPath string // resolved via CoverResolver on emit
Books []*models.MediaItem
}
// Series is the detail-shape series with books ordered by series_index.
type Series struct {
ID string // lowercased series_name
Name string // canonical series_name
Books []*models.MediaItem
}
// TokenStore persists and validates the ABS JWT JTIs that back the
// revocable-token surface (login, refresh, logout, bearerAuth).
// Real impl: a thin repo over the abs_tokens table added in Stage 2.
type TokenStore interface {
// InsertToken persists a newly minted JTI.
InsertToken(ctx context.Context, tok ABSToken) error
// GetTokenByJTI looks up a token by its JTI; returns ErrNotFound if absent.
GetTokenByJTI(ctx context.Context, jti string) (ABSToken, error)
// RevokeTokenByJTI marks a JTI as revoked (sets revoked_at).
RevokeTokenByJTI(ctx context.Context, jti string) error
// RevokeTokenIfActive atomically marks an unrevoked token as revoked and
// returns its previous row. Returns ErrNotFound when the token is absent or
// was already revoked.
RevokeTokenIfActive(ctx context.Context, jti string) (ABSToken, error)
// RevokeTokensForPrincipal revokes every active access/refresh token for a
// user profile. Logout uses this to invalidate refresh tokens as well as the
// presented access token.
RevokeTokensForPrincipal(ctx context.Context, userID, profileID string) error
// TouchToken extends last_seen_at for active-session bookkeeping.
TouchToken(ctx context.Context, jti string) error
}
// ABSToken is the in-memory representation of a persisted JTI row.
type ABSToken struct {
ID string
UserID string
ProfileID string
Type string
JTI string
ExpiresAt time.Time
RevokedAt *time.Time
}
// ProfileCredentialValidator validates a (username, password) pair against
// silo's auth backend. Implemented by an adapter over internal/auth in a
// later stage.
type ProfileCredentialValidator interface {
Validate(ctx context.Context, username, password string) (userID string, profileID string, displayName string, err error)
}
// AccessResolver resolves the ABS-authenticated user/profile into the same
// effective catalog access filter used by silo's native API.
type AccessResolver interface {
ResolveABSAccess(ctx context.Context, userID, profileID string) (catalog.AccessFilter, error)
}
// EventPublisher delivers a realtime event to Socket.io clients. May be nil;
// handlers guard with publish/broadcast nil-safe wrappers.
type EventPublisher interface {
Publish(userID, event string, payload any)
Broadcast(event string, payload any)
}
// SocketIOServer exposes the Socket.io HTTP handler. The concrete
// implementation lives in internal/audiobooks/abssocket. Keeping the
// interface here avoids a circular import: handler.go uses it, abssocket
// imports abs for ParseToken/EventPublisher, and the wiring is done by the
// caller (service.go or main.go) that imports both packages.
type SocketIOServer interface {
Handler() http.Handler
}
// Recommender powers /items/{id}/similar. nil → route returns an empty list.
type Recommender interface {
Similar(ctx context.Context, contentID string, limit int) ([]string, error)
}
// ---------------------------------------------------------------------------
// Config provider
// ---------------------------------------------------------------------------
// ConfigProvider supplies runtime config values the ABS handler needs.
// Keeps the handler decoupled from any particular settings-store shape.
type ConfigProvider interface {
// JWTSecret returns the HMAC-SHA256 signing secret for ABS JWTs.
JWTSecret(ctx context.Context) ([]byte, error)
// AccessTTL / RefreshTTL are the default token lifetimes; zero means
// "use built-in default (24 h / 30 d)".
AccessTTL(ctx context.Context) (time.Duration, error)
RefreshTTL(ctx context.Context) (time.Duration, error)
// StandaloneLoginEnabled reports whether body-creds login is permitted
// (i.e., operator has not disabled it in settings).
StandaloneLoginEnabled(ctx context.Context) (bool, error)
}
// ---------------------------------------------------------------------------
// Dependencies + Handler
// ---------------------------------------------------------------------------
// Dependencies bundles everything the Handler needs at construction time.
type Dependencies struct {
MediaStore MediaStore
TokenStore TokenStore
CredValidator ProfileCredentialValidator
AccessResolver AccessResolver
Config ConfigProvider
Publisher EventPublisher // may be nil
Recommender Recommender // may be nil
LoginLimiter *LoginLimiter // may be nil — one is created if absent
// InstallID returns the current plugin install ID for building
// host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil.
InstallID func() string
// ProgressStore provides access to user_watch_progress for ABS
// progress endpoints. May be nil; handlers degrade gracefully.
ProgressStore ProgressStore
// PlaybackSessionStore persists abs_playback_sessions rows
// (migration 143) for /session/{sid}/sync and /session/{sid}/close.
// May be nil; handlers degrade gracefully.
PlaybackSessionStore ABSPlaybackSessionStore
// BookmarkStore persists ABS bookmark rows (migration 148) for the
// POST/PATCH/DELETE /me/item/{itemId}/bookmark endpoints. May be
// nil; handlers respond 503 when unset.
BookmarkStore BookmarkStore
// CollectionStore persists ABS user-collection rows (migrations 149 + 150).
// May be nil; handlers respond 503 when unset.
CollectionStore CollectionStore
// PlaylistStore persists ABS playlist rows (migrations 151 + 152).
// May be nil; handlers respond 503 when unset.
PlaylistStore PlaylistStore
// SmartCollectionStore persists user_personal_collections rows with
// collection_type='smart' (migration 156 unified the old
// abs_smart_collections table into the canonical store).
// May be nil; handlers respond 503 when unset.
SmartCollectionStore SmartCollectionStore
// RSSFeedStore persists abs_rss_feeds rows (migration 155).
// May be nil; handlers respond 503 when unset.
RSSFeedStore RSSFeedStore
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
// the route is only registered when a non-nil value is supplied.
SocketIO SocketIOServer
// CoverResolver translates a raw silo poster path (e.g.
// "local/audiobooks/.../original.webp") into a fully-qualified URL
// the ABS client can fetch. Optional; when nil, /api/items/{id}/cover
// 404s rather than redirecting to an unreachable relative path.
CoverResolver func(ctx context.Context, path, variant string) string
}
// Handler wires the /abs/api/* and canonical ABS-client paths.
type Handler struct {
deps Dependencies
}
// New constructs an ABS Handler. Sensible defaults are applied for optional
// fields (LoginLimiter, InstallID).
//
// MediaStore is required: many handlers (libraries, items, me, play) deref
// it unconditionally on the request hot path, and a nil store would panic
// the first time a real request hits them. Fail fast at construction so
// misconfigured deployments break at startup rather than silently passing
// /login and crashing on the next request.
func New(deps Dependencies) *Handler {
if deps.MediaStore == nil {
panic("abs.New: MediaStore is required")
}
if deps.LoginLimiter == nil {
deps.LoginLimiter = NewLoginLimiter()
}
if deps.InstallID == nil {
deps.InstallID = func() string { return "silo.audiobooks" }
}
return &Handler{deps: deps}
}
// ---------------------------------------------------------------------------
// Mount
// ---------------------------------------------------------------------------
// Mount registers the ABS-compatible routes on r. Stage 1 registers an empty
// /abs group with the access-log middleware attached; subsequent stages add
// real route handlers.
//
// The dual-mount design (routes at both /abs/api/* and /* roots) is preserved
// here so stage-by-stage handlers land in the right places without needing to
// revisit Mount later.
func (h *Handler) Mount(parent chi.Router) {
parent.Group(func(r chi.Router) {
r.Use(h.accessLog)
h.mountRoutes(r)
})
}
func (h *Handler) mountRoutes(r chi.Router) {
// Discovery + auth endpoints: real ABS exposes these at server ROOT
// (no /api or /abs/api prefix). Mobile clients do `${addr}/ping`,
// `${addr}/login`, etc. Designed to be mounted on a dedicated listener
// so the routes don't collide with silo's SPA catch-all.
for _, prefix := range []string{"", "/abs/api"} {
r.Get(prefix+"/ping", h.handleABSPing)
r.Get(prefix+"/healthcheck", h.handleABSPing) // same body as /ping
r.Get(prefix+"/init", h.handleABSInit)
r.Get(prefix+"/status", h.handleABSStatus)
}
// Stage 2: login (body credentials).
r.Post("/login", h.handleLogin)
r.Post("/abs/api/login", h.handleLogin)
// Token rotation — mobile clients call this every ~22h to avoid the
// 24h access-token interactive re-login trap.
r.Post("/auth/refresh", h.handleRefresh)
r.Post("/abs/api/auth/refresh", h.handleRefresh)
// Logout is mounted OUTSIDE bearerAuth so an expired-access client can
// still sign out (the primary "sign out" UX moment). The handler parses
// the bearer locally, revokes the JTI if parseable, and always returns
// 204 — matches the canonical continuum-plugin behavior.
r.Post("/logout", h.handleLogout)
r.Post("/api/logout", h.handleLogout)
r.Post("/abs/api/logout", h.handleLogout)
r.Post("/abs/api/auth/logout", h.handleLogout) // legacy path
// Unauthenticated cover + author-image routes. Real ABS serves covers
// without auth (getDoesServerImagesRequireToken returns false for our
// version), so mounting these outside bearerAuth avoids 401s.
for _, prefix := range []string{"/abs/api", "/api"} {
r.Get(prefix+"/items/{id}/cover", h.handleItemCover)
r.Get(prefix+"/authors/{id}/image", h.handleAuthorImage)
}
// Session-scoped audio streaming (ABS v2.22.0+ DirectPlay). The Android
// and iOS clients call this WITHOUT a bearer token — the session ID is
// the capability. Mounted at both /public/session and /abs/public/session
// for compatibility with clients that pin either prefix.
for _, prefix := range []string{"", "/abs"} {
r.Get(prefix+"/public/session/{sid}/track/{idx}", h.handlePublicTrack)
r.Head(prefix+"/public/session/{sid}/track/{idx}", h.handlePublicTrack)
}
// Public RSS feed routes — slug is the capability token, no auth.
r.Get("/feed/{slug}.xml", h.handlePublicFeed)
r.Get("/feed/{slug}", h.handlePublicFeed)
r.Get("/feed/{slug}/file/{ino}", h.handlePublicFeedFile)
// Server discovery — unauthenticated. Mounted at both /api and the
// canonical root so curl-style network probes, the official ABS app's
// connect-server flow, and AudioBooth's saved-server liveness check
// all land on the same response.
for _, prefix := range []string{"/abs", "/api"} {
r.Get(prefix+"/ping", h.handlePing)
r.Get(prefix+"/healthcheck", h.handleHealthcheck)
r.Get(prefix+"/init", h.handleInit)
r.Get(prefix+"/auth-settings", h.handleAuthSettings)
}
// Stage 3: playback session + file routes, registered under both the
// legacy /abs/api prefix and the canonical /api prefix that the official
// ABS mobile client builds against (no /abs prefix at server root).
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// POST /api/items/{libraryItemId}/play — start a play session,
// get back a stream URL + ABS-shaped manifest.
r.Post(prefix+"/items/{libraryItemId}/play", h.handlePlayStart)
// GET /api/items/{libraryItemId}/file/{ino} — stream a specific audio file.
// /download variant is the same handler; Content-Disposition is set when
// the path ends in /download.
r.Get(prefix+"/items/{libraryItemId}/file/{ino}", h.handleFileStream)
r.Get(prefix+"/items/{libraryItemId}/file/{ino}/download", h.handleFileStream)
}
})
// Stage 4: progress + session tracking — requires bearerAuth.
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// GET /me/progress — all audiobook progress for the caller
r.Get(prefix+"/me/progress", h.handleGetMyProgress)
// GET /me/progress/{id} — progress for one item
r.Get(prefix+"/me/progress/{libraryItemId}", h.handleGetItemProgress)
// POST /me/progress/{id} — set / update progress (ABS PATCH semantics)
r.Post(prefix+"/me/progress/{libraryItemId}", h.handleSetItemProgress)
// PATCH alias — AudioBooth and the canonical ABS server use
// PATCH for the same write; route both methods to the handler.
r.Patch(prefix+"/me/progress/{libraryItemId}", h.handleSetItemProgress)
// DELETE /me/progress/{id} — clear progress (Reset Progress)
r.Delete(prefix+"/me/progress/{libraryItemId}", h.handleDeleteItemProgress)
// PATCH /me/progress/{id}/{episodeId} — podcast episode
// progress; audiobook-only catalog, so this is a stub.
r.Patch(prefix+"/me/progress/{libraryItemId}/{episodeId}", h.handleSetEpisodeProgress)
// PATCH /session/{sid} — heartbeat: position + time_listening
r.Patch(prefix+"/session/{sid}", h.handleSessionSync)
// POST /session/{sid}/close — finalise the play session
r.Post(prefix+"/session/{sid}/close", h.handleSessionClose)
// Bookmarks — POST/PATCH both upsert; DELETE is idempotent.
r.Post(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_created"))
r.Patch(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_updated"))
r.Delete(prefix+"/me/item/{itemId}/bookmark/{time}", h.handleDeleteBookmark)
// Collections — owner-gated CRUD with cross-user public reads.
r.Get(prefix+"/collections", h.handleListCollections)
// Per-library collections list — bookshelf "Collections" tab
// hits this. Paged envelope with full-shape entries (books[]
// included) so the cover stack renders.
r.Get(prefix+"/libraries/{libraryId}/collections", h.handleListLibraryCollections)
r.Post(prefix+"/collections", h.handleCreateCollection)
r.Get(prefix+"/collections/{id}", h.handleGetCollection)
r.Patch(prefix+"/collections/{id}", h.handleUpdateCollection)
r.Delete(prefix+"/collections/{id}", h.handleDeleteCollection)
r.Post(prefix+"/collections/{id}/book/{bookId}", h.handleAddCollectionBook)
r.Delete(prefix+"/collections/{id}/book/{bookId}", h.handleRemoveCollectionBook)
// Playlists — owner-gated CRUD with cross-user public reads,
// realtime events on every mutation, batch endpoints.
r.Get(prefix+"/playlists", h.handleListPlaylists)
// Per-library playlist list — mobile create-playlist modal
// loads from here before opening the form. Emits
// `{results: [...]}` with full-shape entries.
r.Get(prefix+"/libraries/{libraryId}/playlists", h.handleListLibraryPlaylists)
r.Post(prefix+"/playlists", h.handleCreatePlaylist)
r.Get(prefix+"/playlists/{id}", h.handleGetPlaylist)
r.Patch(prefix+"/playlists/{id}", h.handleUpdatePlaylist)
r.Delete(prefix+"/playlists/{id}", h.handleDeletePlaylist)
r.Post(prefix+"/playlists/{id}/item", h.handleAddPlaylistItem)
r.Post(prefix+"/playlists/{id}/batch/add", h.handleBatchAddPlaylistItems)
r.Post(prefix+"/playlists/{id}/batch/remove", h.handleBatchRemovePlaylistItems)
r.Delete(prefix+"/playlists/{id}/item/{libraryItemId}", h.handleRemovePlaylistItem)
r.Delete(prefix+"/playlists/{id}/item/{libraryItemId}/{episodeId}", h.handleRemovePlaylistEpisode)
// Smart collections — rule-based dynamic groupings.
r.Get(prefix+"/me/smart-collections", h.handleListSmartCollections)
r.Post(prefix+"/me/smart-collections", h.handleCreateSmartCollection)
r.Get(prefix+"/me/smart-collections/{id}", h.handleGetSmartCollection)
r.Get(prefix+"/me/smart-collections/{id}/items", h.handleSmartCollectionItems)
r.Patch(prefix+"/me/smart-collections/{id}", h.handleUpdateSmartCollection)
r.Delete(prefix+"/me/smart-collections/{id}", h.handleDeleteSmartCollection)
// Phase 1 close-out: listening stats / author+series / continue / RSS auth.
r.Get(prefix+"/me/listening-stats", h.handleListeningStats)
r.Get(prefix+"/me/listening-sessions", h.handleListeningSessions)
r.Get(prefix+"/me/listening-sessions/{sid}", h.handleListeningSessionDetail)
r.Get(prefix+"/authors/{id}", h.handleAuthorDetail)
r.Get(prefix+"/series/{id}", h.handleSeriesDetail)
r.Get(prefix+"/me/progress/{itemId}/remove-from-continue-listening", h.handleRemoveFromContinueListening)
r.Get(prefix+"/me/progress/{itemId}/readd-to-continue-listening", h.handleReaddToContinueListening)
r.Get(prefix+"/feeds", h.handleListRSSFeeds)
r.Post(prefix+"/feeds/item/{itemId}/open", h.handleOpenItemFeed)
r.Post(prefix+"/feeds/{id}/close", h.handleCloseFeed)
// Year-in-review stats — AudioBooth's "Year Stats" widget on the
// profile screen. Synthesized from AggregateStats today.
r.Get(prefix+"/me/stats/year/{year}", h.handleYearStats)
// Ebook surface — stubs until the ebook scanner lands.
// Mobile clients call these but degrade cleanly on empty/404.
r.Get(prefix+"/items/{id}/ebook/{fileid}", h.handleEbookFile)
r.Patch(prefix+"/items/{id}/ebook/{fileid}/status", h.handleEbookStatus)
// E-reader devices + ebook email delivery — empty list / 503
// until SMTP integration is wired.
r.Get(prefix+"/me/ereader-devices", h.handleListEreaderDevices)
r.Post(prefix+"/emails/send-ebook-to-device", h.handleSendEbookToDevice)
// Podcast stubs — audiobook-only catalog in v1. Endpoints
// return empty-but-well-formed shapes so the mobile UI doesn't
// crash on the podcast surfaces.
r.Post(prefix+"/podcasts/feed", h.handlePodcastFeed)
r.Post(prefix+"/items/{libraryItemId}/play/{episodeId}", h.handlePlayEpisode)
r.Get(prefix+"/libraries/{libraryId}/recent-episodes", h.handleRecentEpisodes)
r.Get(prefix+"/search/podcast", h.handleSearchPodcast)
}
})
// Stage 5: browse routes (libraries, items, item detail, me, similar,
// continue-listening) + author/series/search/personalized stubs.
// Requires bearerAuth.
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// Current user object.
r.Get(prefix+"/me", h.handleMe)
// Real-ABS /authorize: validates the bearer and re-mints the
// /me envelope so the client can resume without retyping creds.
r.Post(prefix+"/authorize", h.handleABSAuthorize)
// Continue Listening shelf.
r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress)
// Library list + detail.
r.Get(prefix+"/libraries", h.handleLibraries)
r.Get(prefix+"/libraries/{libraryId}", h.handleLibraryDetail)
// Browse items in a library.
r.Get(prefix+"/libraries/{libraryId}/items", h.handleLibraryItems)
// Author / series / search / personalized — stubbed.
r.Get(prefix+"/libraries/{libraryId}/authors", h.handleLibraryAuthors)
r.Get(prefix+"/libraries/{libraryId}/series", h.handleLibrarySeries)
r.Get(prefix+"/libraries/{libraryId}/search", h.handleLibrarySearch)
r.Get(prefix+"/libraries/{libraryId}/personalized", h.handlePersonalized)
// Single item detail.
r.Get(prefix+"/items/{id}", h.handleItem)
// Similar items (optional Recommender; empty list when nil).
r.Get(prefix+"/items/{id}/similar", h.handleSimilarItems)
}
})
// Stage 6: Socket.io realtime endpoint.
if h.deps.SocketIO != nil {
r.Mount("/socket.io", h.deps.SocketIO.Handler())
r.Mount("/abs/socket.io", h.deps.SocketIO.Handler())
}
// TODO: social / collection routes (bookmarks, smart-collections,
// collections, playlists, RSS feeds, author/series detail, listening stats)
}
// ---------------------------------------------------------------------------
// Auth context helpers (used by bearerAuth middleware + handlers)
// ---------------------------------------------------------------------------
// ctxKey is the unexported ABS auth context key.
type ctxKey struct{}
// ctxAuth carries the decoded ABS JWT claims for the lifetime of a request.
type ctxAuth struct {
UserID string
ProfileID string
JTI string
Token string // raw bearer token
}
// absAuthFrom extracts ABS auth from the request context. Returns (zero, false)
// when bearerAuth middleware hasn't run (unauthenticated routes).
func absAuthFrom(r *http.Request) (ctxAuth, bool) {
a, ok := r.Context().Value(ctxKey{}).(ctxAuth)
return a, ok
}
func (h *Handler) accessFilterForAuth(ctx context.Context, a ctxAuth) (catalog.AccessFilter, error) {
if h.deps.AccessResolver != nil {
return h.deps.AccessResolver.ResolveABSAccess(ctx, a.UserID, a.ProfileID)
}
filter := catalog.AccessFilter{ProfileID: a.ProfileID}
if uid, err := strconv.Atoi(a.UserID); err == nil {
filter.UserID = uid
}
return filter, nil
}
func (h *Handler) accessFilterFromRequest(r *http.Request) (catalog.AccessFilter, bool, error) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
return catalog.AccessFilter{}, false, nil
}
filter, err := h.accessFilterForAuth(r.Context(), a)
return filter, true, err
}
func emptyAccessFilter() catalog.AccessFilter {
return catalog.AccessFilter{}
}
func sameABSPrincipal(a ctxAuth, userID, profileID string) bool {
return a.UserID == userID && a.ProfileID == profileID
}
// bearerAuth is the authentication middleware for protected ABS routes.
// It reads the bearer token from the Authorization header or ?token= query
// param, validates the JWT, checks the JTI isn't revoked, and injects
// ctxAuth into the request context.
//
// Placeholder implementation — full validation logic lands in Stage 2 when
// TokenStore and ConfigProvider are wired to real backing stores.
func (h *Handler) bearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
raw = r.URL.Query().Get("token")
}
if raw == "" {
slog.Debug("abs bearerAuth: no token", "path", r.URL.Path, "remote", r.RemoteAddr)
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if h.deps.Config == nil || h.deps.TokenStore == nil {
slog.Warn("abs bearerAuth: deps not wired",
"have_config", h.deps.Config != nil,
"have_token_store", h.deps.TokenStore != nil,
"path", r.URL.Path)
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
slog.Error("abs bearerAuth: jwt secret fetch failed", "err", err, "path", r.URL.Path)
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
claims, err := ParseToken(secret, raw)
if err != nil {
slog.Debug("abs bearerAuth: parse failed", "err", err, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if claims.Type != "access" {
slog.Debug("abs bearerAuth: wrong token type", "type", claims.Type, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
if err != nil {
slog.Debug("abs bearerAuth: jti lookup failed",
"jti", claims.JTI, "err", err, "path", r.URL.Path)
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
if row.RevokedAt != nil {
slog.Debug("abs bearerAuth: jti revoked", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
if row.UserID != "" && row.UserID != claims.UserID {
slog.Debug("abs bearerAuth: token user mismatch", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if row.ProfileID != "" && row.ProfileID != claims.ProfileID {
slog.Debug("abs bearerAuth: token profile mismatch", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if row.Type != "" && row.Type != "access" {
slog.Debug("abs bearerAuth: persisted token type mismatch", "type", row.Type, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if !row.ExpiresAt.IsZero() && time.Now().After(row.ExpiresAt) {
slog.Debug("abs bearerAuth: persisted token expired", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "token expired", http.StatusUnauthorized)
return
}
_ = h.deps.TokenStore.TouchToken(r.Context(), claims.JTI)
ctx := context.WithValue(r.Context(), ctxKey{}, ctxAuth{
UserID: claims.UserID,
ProfileID: claims.ProfileID,
JTI: claims.JTI,
Token: raw,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ---------------------------------------------------------------------------
// Publisher nil-safe wrappers
// ---------------------------------------------------------------------------
func (h *Handler) publish(userID, event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Publish(userID, event, payload)
}
func (h *Handler) broadcast(event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Broadcast(event, payload)
}
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
// absBaseURL returns the server address prefix ABS clients should use to
// resolve response-embedded URLs.
//
// - Host-proxied (X-Silo-User-Id header present): returns the plugin-proxy
// path "<scheme>://<host>/api/v1/plugins/<installID>".
// - Standalone listener: returns "<scheme>://<host>" — origin only.
//
// Honors X-Forwarded-Proto / X-Forwarded-Host for TLS-terminating proxies.
func (h *Handler) absBaseURL(r *http.Request) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if r.Header.Get("X-Silo-User-Id") != "" {
return scheme + "://" + host + "/api/v1/plugins/" + h.deps.InstallID()
}
return scheme + "://" + host
}
// ---------------------------------------------------------------------------
// Shared response helpers (used by handlers across multiple stages)
// ---------------------------------------------------------------------------
// writeJSON serialises v as JSON and writes it with the given HTTP status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// readPagedQuery extracts `limit` and `page` from query params. Real ABS
// treats limit=0 as "return all" (not "return zero rows") — we surface that
// intent and let callers short-circuit pagination.
func readPagedQuery(r *http.Request, defaultLimit int) (limit, page int) {
limit = defaultLimit
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
limit = n
}
}
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
page = n
}
}
return limit, page
}
// pagedEnvelope builds the standard ABS pagination response shape. All eight
// fields are always emitted (no omitempty) because ABS clients branch on
// their presence (sortBy, filterBy, minified).
func pagedEnvelope(results any, total, limit, page int, sortBy string, sortDesc bool, filterBy string, minified bool, include string) map[string]any {
return map[string]any{
"results": results,
"total": total,
"limit": limit,
"page": page,
"sortBy": sortBy,
"sortDesc": sortDesc,
"filterBy": filterBy,
"minified": minified,
"include": include,
}
}
+182
View File
@@ -0,0 +1,182 @@
package abs
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
)
// resolveDefaultLibrary returns the first audiobook library (the canonical
// "default" for response embedding) or a virtual fallback when the store
// is empty or errors. Centralizes the snippet that handleItem,
// handleSimilarItems, and handleItemsInProgress all need so the fallback
// shape stays consistent across all three response paths.
func (h *Handler) resolveDefaultLibrary(ctx context.Context, filters ...catalog.AccessFilter) AudiobookLibrary {
access := emptyAccessFilter()
if len(filters) > 0 {
access = filters[0]
}
if libs, err := h.deps.MediaStore.ListAudiobookLibraries(ctx, access); err == nil && len(libs) > 0 {
return libs[0]
}
return AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"}
}
// handleItem — GET /abs/api/items/{id} (and /api/items/{id})
//
// Returns the full ABS LibraryItem with audio track details for the given
// audiobook. The ABS mobile app fetches this when the user opens the
// item-detail page; it reads media.tracks.length to decide whether to render
// the play button and uses the track metadata for offline-download decisions.
func (h *Handler) handleItem(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID, access)
if err != nil {
http.Error(w, "load files: "+err.Error(), http.StatusInternalServerError)
return
}
lib := h.resolveDefaultLibrary(r.Context(), access)
baseURL := h.absBaseURL(r)
result := siloItemToLibraryItemDetail(item, files, lib, baseURL)
writeJSON(w, http.StatusOK, result)
}
// handleSimilarItems — GET /abs/api/items/{id}/similar
//
// Returns similar audiobooks in the canonical ABS paged envelope so
// mobile clients can render the "Similar" rail. Sort metadata is
// "relevance" desc to match continuum-plugin-audiobooks; the envelope
// is emitted even when empty so clients that iterate
// `results`/`total` don't crash.
func (h *Handler) handleSimilarItems(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "id")
if contentID == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
const limit = 10
emptyEnvelope := pagedEnvelope([]any{}, 0, limit, 0, "relevance", true, "", false, "")
if h.deps.Recommender == nil {
writeJSON(w, http.StatusOK, emptyEnvelope)
return
}
ids, err := h.deps.Recommender.Similar(r.Context(), contentID, limit)
if err != nil || len(ids) == 0 {
writeJSON(w, http.StatusOK, emptyEnvelope)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
lib := h.resolveDefaultLibrary(r.Context(), access)
baseURL := h.absBaseURL(r)
out := make([]LibraryItem, 0, len(ids))
for _, id := range ids {
si, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), id, access)
if err != nil || si == nil {
continue
}
out = append(out, siloItemToLibraryItem(si, lib, baseURL))
}
writeJSON(w, http.StatusOK, pagedEnvelope(out, len(out), limit, 0, "relevance", true, "", false, ""))
}
// handleItemsInProgress — GET /abs/api/me/items-in-progress
//
// Returns the Continue Listening shelf. Queries the ProgressStore for in-
// progress rows, then hydrates each with a summary LibraryItem from the
// catalog. Items without a matching catalog entry are skipped silently.
func (h *Handler) handleItemsInProgress(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.ProgressStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"libraryItems": []any{}})
return
}
rows, err := h.deps.ProgressStore.ListProgressForAudiobooks(r.Context(), a.UserID, a.ProfileID, 25)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
lib := h.resolveDefaultLibrary(r.Context(), access)
baseURL := h.absBaseURL(r)
items := make([]any, 0, len(rows))
for _, p := range rows {
if p.IsFinished || p.CurrentSeconds <= 0 {
continue
}
si, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), p.ContentID, access)
if err != nil || si == nil {
continue
}
li := siloItemToLibraryItem(si, lib, baseURL)
items = append(items, map[string]any{
"id": li.ID,
"libraryId": li.LibraryID,
"folderId": li.FolderID,
"mediaType": li.MediaType,
"media": li.Media,
"numTracks": li.NumTracks,
"addedAt": li.AddedAt,
"updatedAt": li.UpdatedAt,
"userMediaProgress": map[string]any{
"id": a.UserID + "-" + p.ContentID,
"libraryItemId": p.ContentID,
"currentTime": p.CurrentSeconds,
"progress": p.ProgressPct,
"isFinished": p.IsFinished,
"lastUpdate": p.UpdatedAt.UnixMilli(),
},
})
}
writeJSON(w, http.StatusOK, map[string]any{"libraryItems": items})
}
+91
View File
@@ -0,0 +1,91 @@
// Package abs — JWT minting and validation for the ABS-compat layer.
package abs
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims are the unified ABS JWT claim set. Different `Type` values denote
// access, refresh, or session tokens.
type Claims struct {
Type string `json:"type"` // access | refresh | session
UserID string `json:"sub"` // user id
ProfileID string `json:"pid,omitempty"` // empty = primary profile
JTI string `json:"jti"` // token id (revocable)
DeviceID string `json:"device_id,omitempty"`
SessionID string `json:"sid,omitempty"`
BookID string `json:"bid,omitempty"`
FileIdx int `json:"fidx,omitempty"`
jwt.RegisteredClaims
}
// IssueAccessToken mints a stateless access JWT.
func IssueAccessToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "access",
UserID: userID,
ProfileID: profileID,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// IssueRefreshToken mints a refresh JWT.
func IssueRefreshToken(secret []byte, userID, profileID, jti string, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "refresh",
UserID: userID,
ProfileID: profileID,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// IssueSessionToken mints a streaming-capability JWT used in the public route.
func IssueSessionToken(secret []byte, userID, sessionID, bookID string, fileIdx int, ttl time.Duration) (string, error) {
return issueJWT(secret, Claims{
Type: "session",
UserID: userID,
SessionID: sessionID,
BookID: bookID,
FileIdx: fileIdx,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
}
// ParseToken validates and decodes a JWT. Returns an error on signature
// mismatch or expiry.
func ParseToken(secret []byte, raw string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
if !tok.Valid {
return nil, errors.New("token invalid")
}
return claims, nil
}
func issueJWT(secret []byte, c Claims) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return t.SignedString(secret)
}
@@ -0,0 +1,894 @@
package abs
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// /libraries list + single-library detail
// ---------------------------------------------------------------------------
// handleLibraries — GET /abs/api/libraries (and /api/libraries)
//
// Returns the list of audiobook media_folders. ABS clients call this to
// populate the library picker and to know which library IDs are valid.
func (h *Handler) handleLibraries(w http.ResponseWriter, r *http.Request) {
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
if err != nil {
http.Error(w, "list libraries: "+err.Error(), http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(libs))
for _, lib := range libs {
out = append(out, audiobookLibraryMap(lib))
}
writeJSON(w, http.StatusOK, map[string]any{"libraries": out})
}
// handleLibraryDetail — GET /abs/api/libraries/{libraryId}
func (h *Handler) handleLibraryDetail(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
resp := map[string]any{
"library": audiobookLibraryMap(lib),
}
if includeHas(r.URL.Query().Get("include"), "filterdata") {
resp["filterdata"] = h.buildFilterData(r, lib)
resp["issues"] = 0
// numUserPlaylists drives the bottom-nav "Playlists" tab
// visibility on the ABS mobile client (BookshelfNavBar.vue:25
// gates the tab on `numUserPlaylists` being truthy). Comment
// in plugins/server.js:129 confirms "precise number is not
// necessary" — we just need a non-zero count when the caller
// has any playlists, so the ListUserPlaylists len suffices.
resp["numUserPlaylists"] = h.countUserPlaylists(r)
}
writeJSON(w, http.StatusOK, resp)
}
// countUserPlaylists returns the playlist count for the authenticated
// caller, or 0 when no auth / no store is wired (open-mode endpoints
// still serve library detail).
func (h *Handler) countUserPlaylists(r *http.Request) int {
if h.deps.PlaylistStore == nil {
return 0
}
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
return 0
}
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
if err != nil {
return 0
}
return len(rows)
}
// buildFilterData populates the filter sheet payload from the same store
// queries /libraries/{id}/authors and /libraries/{id}/series use. Caps at
// 5000 per kind to keep the response bounded; libraries larger than that
// will paginate via the dedicated /authors and /series endpoints.
//
// Narrators / genres / publishers / languages / tags are left as empty
// arrays for now — Phase 1 will populate them once the catalog has the
// aggregations indexed. iOS tolerates empty filter dropdowns gracefully.
//
// The two fetch/convert blocks deliberately stay un-abstracted: they target
// different store methods (ListLibraryAuthors / ListLibrarySeries) and
// produce different output types (AuthorObj / SeriesObj). A generic helper
// would need closures at each call site that are longer than the inlined
// code; the structural parallelism is the cheapest form here.
func (h *Handler) buildFilterData(r *http.Request, lib AudiobookLibrary) map[string]any {
ctx := r.Context()
const fetchCap = 5000
access, _, _ := h.accessFilterFromRequest(r)
authorObjs := []AuthorObj{}
if rows, err := h.deps.MediaStore.ListLibraryAuthors(ctx, lib.ID, fetchCap, access); err == nil {
for _, a := range rows {
authorObjs = append(authorObjs, AuthorObj{ID: a.ID, Name: a.Name})
}
}
seriesObjs := []SeriesObj{}
if rows, err := h.deps.MediaStore.ListLibrarySeries(ctx, lib.ID, fetchCap, access); err == nil {
for _, s := range rows {
seriesObjs = append(seriesObjs, SeriesObj{ID: s.ID, Name: s.Name})
}
}
return map[string]any{
"authors": authorObjs,
"series": seriesObjs,
"narrators": []string{},
"genres": []string{},
"publishers": []string{},
"languages": []string{},
"tags": []string{},
}
}
// ---------------------------------------------------------------------------
// /libraries/{libraryId}/items — paginated audiobook browse
// ---------------------------------------------------------------------------
// handleLibraryItems — GET /abs/api/libraries/{libraryId}/items
//
// Returns a paginated, optionally filtered and/or collapsed-by-series list
// of audiobook LibraryItems from silo's media_items table for the requested
// library. Supports the standard ABS query params:
//
// - limit / page — pagination
// - minified=1 — slim response (no tracks, flat author/series)
// - filter=<kind>.<b64value> — local-side filter (authors, series, narrators, progress)
// - collapseseries=1 — fold books by series; returns one entry per series
//
// Note: sort pushdown is not yet implemented (returns insertion order from
// the store). Filter is applied locally after fetching. These limitations
// are consistent with the plugin at its initial launch.
func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
q := r.URL.Query()
limit, page := readPagedQuery(r, 30)
sortBy := q.Get("sort")
sortDesc := q.Get("desc") == "1"
filterBy := q.Get("filter")
minified := q.Get("minified") == "1"
collapseSeries := q.Get("collapseseries") == "1"
include := q.Get("include")
filter, hasFilter := ParseFilter(filterBy)
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
// Fetch the full visible library when filtering so local post-filter
// cannot truncate candidates before applying the predicate.
fetchLimit := limit
if hasFilter || collapseSeries || limit == 0 {
fetchLimit = 0
}
fetchOffset := 0
if !hasFilter && !collapseSeries && limit > 0 {
fetchOffset = page * limit
}
items, total, err := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, fetchLimit, fetchOffset, access)
if err != nil {
http.Error(w, "list audiobooks: "+err.Error(), http.StatusInternalServerError)
return
}
// Convert to ABS LibraryItem shape.
baseURL := h.absBaseURL(r)
all := make([]LibraryItem, 0, len(items))
for _, item := range items {
all = append(all, siloItemToLibraryItem(item, lib, baseURL))
}
// Local filter (post-fetch).
if hasFilter {
filtered := make([]LibraryItem, 0, len(all))
for _, it := range all {
if filter.Matches(it, false, false, false) {
filtered = append(filtered, it)
}
}
all = filtered
total = len(all)
}
// Collapse-by-series before paging.
collapsed := all
if collapseSeries {
collapsed = CollapseBySeries(all)
total = len(collapsed)
}
// Slice for page/limit.
pageStart, pageEnd := 0, len(collapsed)
if limit > 0 && (hasFilter || collapseSeries) {
pageStart = page * limit
if pageStart > len(collapsed) {
pageStart = len(collapsed)
}
pageEnd = pageStart + limit
if pageEnd > len(collapsed) {
pageEnd = len(collapsed)
}
}
pageSlice := collapsed[pageStart:pageEnd]
// Serialise.
var results any
if minified {
mins := make([]MinifiedLibraryItem, len(pageSlice))
for i, it := range pageSlice {
mins[i] = Minify(it)
}
results = mins
} else {
results = pageSlice
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, sortBy, sortDesc, filterBy, minified, include))
}
// ---------------------------------------------------------------------------
// Cover and stub endpoints for authors / series / search / personalized
// ---------------------------------------------------------------------------
// handleItemCover — GET /abs/api/items/{id}/cover (unauthenticated)
//
// Returns the audiobook's cover image. Currently redirects to silo's native
// cover endpoint; a later stage may proxy the bytes directly.
func (h *Handler) handleItemCover(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID, emptyAccessFilter())
if err != nil || item == nil {
http.NotFound(w, r)
return
}
if item.PosterPath == "" {
http.NotFound(w, r)
return
}
target := item.PosterPath
// Raw silo paths (e.g. "local/audiobooks/.../original.webp") need to
// be resolved into a real URL via the CoverResolver before redirect;
// otherwise the client follows a relative path that doesn't exist on
// the ABS listener.
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
if h.deps.CoverResolver != nil {
if resolved := h.deps.CoverResolver(r.Context(), target, "card"); resolved != "" {
target = resolved
} else {
http.NotFound(w, r)
return
}
} else {
http.NotFound(w, r)
return
}
}
http.Redirect(w, r, target, http.StatusFound)
}
// handleAuthorImage — GET /authors/{id}/image. Public unauthenticated
// route (mounted outside bearerAuth in handler.go). Uses MediaStore
// to resolve the people row, then CoverResolver to mint a presigned
// URL and 302-redirect to it.
func (h *Handler) handleAuthorImage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
author, err := h.deps.MediaStore.GetAuthorByID(r.Context(), id, emptyAccessFilter())
if err != nil || author.PosterPath == "" {
http.Error(w, "author image not found", http.StatusNotFound)
return
}
if h.deps.CoverResolver == nil {
http.Error(w, "image resolver not configured", http.StatusServiceUnavailable)
return
}
url := h.deps.CoverResolver(r.Context(), author.PosterPath, "")
if url == "" {
http.Error(w, "image resolution failed", http.StatusNotFound)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
// handleLibraryAuthors — GET /abs/api/libraries/{id}/authors
// Lists audiobook authors aggregated from item_people kind=7, including
// per-author book counts. Returns the canonical ABS paged envelope to
// match the continuum-plugin-audiobooks shape verbatim.
func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
limit, page := readPagedQuery(r, 50)
// Fetch the full list (capped at 5000) and paginate locally so the
// envelope's total reflects real DB count, not the page slice length.
// ABS clients use total to decide whether to fetch page 2.
const fetchCap = 5000
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
authors, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, fetchCap, access)
if err != nil {
http.Error(w, "list authors: "+err.Error(), http.StatusInternalServerError)
return
}
libID := audiobookLibraryID(lib)
total := len(authors)
// Local slice for the requested page.
// ABS contract: limit=0 means "return all".
var pageAuthors []AuthorSummary
if limit == 0 {
pageAuthors = authors
} else {
start := page * limit
end := start + limit
if start > total {
start = total
}
if end > total {
end = total
}
pageAuthors = authors[start:end]
}
results := make([]map[string]any, 0, len(pageAuthors))
for _, a := range pageAuthors {
results = append(results, map[string]any{
"id": a.ID,
"name": a.Name,
"numBooks": a.NumBooks,
"libraryId": libID,
})
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, ""))
}
// handleLibrarySeries — GET /abs/api/libraries/{id}/series
// Lists audiobook series. Single-book series are filtered out by the
// store query since they're not useful as series. Returns the canonical
// ABS paged envelope; addedAt is 0 because the v1 catalog has no series
// added-at column (real ABS clients tolerate the placeholder).
func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
limit, page := readPagedQuery(r, 25)
const fetchCap = 5000
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, fetchCap, access)
if err != nil {
http.Error(w, "list series: "+err.Error(), http.StatusInternalServerError)
return
}
libID := audiobookLibraryID(lib)
baseURL := h.absBaseURL(r)
total := len(series)
// ABS contract: limit=0 means "return all".
var pageSeries []SeriesSummary
if limit == 0 {
pageSeries = series
} else {
start := page * limit
end := start + limit
if start > total {
start = total
}
if end > total {
end = total
}
pageSeries = series[start:end]
}
results := make([]map[string]any, 0, len(pageSeries))
for _, s := range pageSeries {
// books[] is what LazySeriesCard reads to populate the
// GroupCover stack. Each entry is a minified LibraryItem with
// the cover URL on media.coverPath; the mobile client's
// globals/getLibraryItemCoverSrc getter requires this field
// to render any cover image, otherwise the card falls back
// to a name-only placeholder.
books := make([]map[string]any, 0, len(s.Books))
for _, bp := range s.Books {
updatedMs := int64(0)
if !bp.UpdatedAt.IsZero() {
updatedMs = bp.UpdatedAt.UnixMilli()
}
books = append(books, map[string]any{
"id": bp.ContentID,
"libraryId": libID,
"mediaType": LibraryMediaType,
"updatedAt": updatedMs,
"media": map[string]any{
"coverPath": baseURL + "/api/items/" + bp.ContentID + "/cover",
"metadata": map[string]any{"title": bp.Title},
},
})
}
results = append(results, map[string]any{
"id": s.ID,
"name": s.Name,
"numBooks": s.NumBooks,
"libraryId": libID,
"addedAt": 0,
"books": books,
})
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, ""))
}
// handleLibrarySearch — GET /abs/api/libraries/{id}/search?q=…&limit=…
// Returns matching books grouped under "book", with empty arrays for the
// other ABS-standard buckets (podcast, series, authors, tags). Bucket
// names match continuum-plugin-audiobooks exactly: note "authors" plural,
// not "author" — ABS mobile clients key off the plural form and a
// singular bucket is silently dropped.
func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 12
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 50 {
limit = n
}
empty := map[string]any{
"book": []any{},
"podcast": []any{},
"series": []any{},
"authors": []any{},
"tags": []any{},
}
if q == "" {
writeJSON(w, http.StatusOK, empty)
return
}
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
items, err := h.deps.MediaStore.SearchAudiobooks(r.Context(), lib.ID, q, limit, access)
if err != nil {
http.Error(w, "search: "+err.Error(), http.StatusInternalServerError)
return
}
baseURL := h.absBaseURL(r)
books := make([]map[string]any, 0, len(items))
for _, it := range items {
books = append(books, map[string]any{
"libraryItem": siloItemToLibraryItem(it, lib, baseURL),
"matchKey": "title",
"matchText": it.Title,
})
}
out := empty
out["book"] = books
writeJSON(w, http.StatusOK, out)
}
// handlePersonalized — GET /abs/api/libraries/{id}/personalized
//
// Emits the canonical six-shelf Home tab payload that ABS mobile clients
// expect: continue-listening, continue-series, newest, recent-series,
// discover, listen-again. Shelves we don't yet populate (continue-series,
// listen-again) ship with empty entities/total — the client iterates the
// shelf list by id and skips empties cleanly, but it crashes on a missing
// shelf id. Matches continuum-plugin-audiobooks/handlePersonalized layout.
func (h *Handler) handlePersonalized(w http.ResponseWriter, r *http.Request) {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.MediaStore == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
baseURL := h.absBaseURL(r)
const shelfLimit = 10
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
shelves := []map[string]any{
{"id": "continue-listening", "label": "Continue Listening", "labelStringKey": "LabelContinueListening", "type": "book", "entities": []any{}, "total": 0},
{"id": "continue-series", "label": "Continue Series", "labelStringKey": "LabelContinueSeries", "type": "book", "entities": []any{}, "total": 0},
{"id": "newest", "label": "Newest", "labelStringKey": "LabelNewest", "type": "book", "entities": []any{}, "total": 0},
{"id": "recent-series", "label": "Recent Series", "labelStringKey": "LabelRecentSeries", "type": "series", "entities": []any{}, "total": 0},
{"id": "discover", "label": "Discover", "labelStringKey": "LabelDiscover", "type": "book", "entities": []any{}, "total": 0},
{"id": "listen-again", "label": "Listen Again", "labelStringKey": "LabelListenAgain", "type": "book", "entities": []any{}, "total": 0},
}
if items, err := h.deps.MediaStore.ListContinueListening(r.Context(), a.UserID, a.ProfileID, lib.ID, shelfLimit, access); err == nil && len(items) > 0 {
shelves[0]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[0]["total"] = len(items)
}
if items, err := h.deps.MediaStore.ListRecentlyAdded(r.Context(), lib.ID, shelfLimit, access); err == nil && len(items) > 0 {
shelves[2]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[2]["total"] = len(items)
}
libID := audiobookLibraryID(lib)
if series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, shelfLimit, access); err == nil && len(series) > 0 {
recent := make([]map[string]any, 0, len(series))
for _, s := range series {
recent = append(recent, map[string]any{
"id": s.ID,
"name": s.Name,
"numBooks": s.NumBooks,
"libraryId": libID,
"books": []any{},
})
}
shelves[3]["entities"] = recent
shelves[3]["total"] = len(recent)
}
if items, err := h.deps.MediaStore.ListDiscover(r.Context(), lib.ID, shelfLimit, access); err == nil && len(items) > 0 {
shelves[4]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[4]["total"] = len(items)
}
writeJSON(w, http.StatusOK, shelves)
}
// minifiedSlice converts a batch of MediaItems into ABS Minified entries.
func minifiedSlice(items []*models.MediaItem, lib AudiobookLibrary, baseURL string) []MinifiedLibraryItem {
out := make([]MinifiedLibraryItem, 0, len(items))
for _, it := range items {
out = append(out, Minify(siloItemToLibraryItem(it, lib, baseURL)))
}
return out
}
// ---------------------------------------------------------------------------
// Library resolver
// ---------------------------------------------------------------------------
// resolveLibrary looks up the library identified by the {libraryId} URL
// param, handling the virtual "silo-audiobooks" sentinel. Returns (lib, true)
// on success or writes a 404 and returns (zero, false) on failure.
func (h *Handler) resolveLibrary(w http.ResponseWriter, r *http.Request) (AudiobookLibrary, bool) {
idStr := chi.URLParam(r, "libraryId")
if idStr == "" {
idStr = chi.URLParam(r, "id")
}
access, _, err := h.accessFilterFromRequest(r)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return AudiobookLibrary{}, false
}
libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
if err != nil {
http.Error(w, "list libraries: "+err.Error(), http.StatusInternalServerError)
return AudiobookLibrary{}, false
}
// Virtual sentinel → first library.
if idStr == "" || idStr == VirtualLibraryID {
if len(libs) > 0 {
return libs[0], true
}
// No libraries configured yet: return a virtual one so ABS clients
// still get a sensible (empty) browse response.
return AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"}, true
}
n, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "library not found", http.StatusNotFound)
return AudiobookLibrary{}, false
}
for _, lib := range libs {
if lib.ID == n {
return lib, true
}
}
http.Error(w, "library not found", http.StatusNotFound)
return AudiobookLibrary{}, false
}
// ---------------------------------------------------------------------------
// silo MediaItem → ABS LibraryItem translation
// ---------------------------------------------------------------------------
// siloItemToLibraryItem converts a silo MediaItem (type='audiobook') into the
// ABS LibraryItem wire shape for browse-list responses (no audio tracks; only
// metadata + duration summary). File-level tracks are populated only on the
// item-detail handler (handleItem).
func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL string) LibraryItem {
meta := siloItemToMetadata(item)
libID := audiobookLibraryID(lib)
// Duration: Runtime field on MediaItem is in minutes for video; for
// audiobooks it stores the total seconds (set by the scanner Stage 2
// extension). Convert from the int field.
duration := float64(item.Runtime) // seconds
// Always point coverPath at our /api/items/{id}/cover endpoint rather
// than the raw silo PosterPath. Storage paths like
// "local/audiobooks/.../original.webp" mean nothing to an ABS client;
// our cover handler resolves them via the CoverResolver before
// redirecting to the real URL.
coverPath := baseURL + "/api/items/" + item.ContentID + "/cover"
addedAtMs := int64(0)
if item.AddedAt != nil {
addedAtMs = item.AddedAt.UnixMilli()
}
updatedAtMs := item.UpdatedAt.UnixMilli()
return LibraryItem{
ID: item.ContentID,
Ino: item.ContentID, // stable item-level ino; matches real-ABS shape
LibraryID: libID,
FolderID: VirtualFolderID,
Path: "",
RelPath: "",
MtimeMs: addedAtMs,
CtimeMs: addedAtMs,
BirthtimeMs: addedAtMs,
MediaType: LibraryMediaType,
Media: LibraryItemMedia{
Metadata: meta,
Duration: duration,
CoverPath: coverPath,
AudioFiles: []AudioTrack{},
Tracks: []AudioTrack{},
Chapters: []ChapterABS{},
NumTracks: 0, // populated by item-detail handler
Tags: []string{},
},
AddedAt: addedAtMs,
UpdatedAt: updatedAtMs,
}
}
// siloItemToMetadata extracts the ABS Metadata block from a silo MediaItem.
// Authors and narrators are sourced from item.People; series from the
// audiobook_series table hydrated onto the MediaItem; publisher from Studios.
//
// Strict 3rd-party clients (Plappa, AudioBookShelfFully) require id on
// every author/series entry and non-nil tags/genres arrays. We surface
// IDs from item_people.id (authors) and slugify(name) (series).
func siloItemToMetadata(item *models.MediaItem) Metadata {
authors := make([]AuthorObj, 0)
narrators := make([]string, 0)
for _, p := range item.People {
switch p.Kind {
case models.PersonKindAuthor:
authors = append(authors, AuthorObj{
ID: strconv.FormatInt(p.ID, 10),
Name: p.Name,
})
case models.PersonKindNarrator:
narrators = append(narrators, p.Name)
}
}
series := make([]SeriesObj, 0, len(item.AudiobookSeries))
for _, membership := range item.AudiobookSeries {
name := strings.TrimSpace(membership.Name)
if name == "" {
continue
}
obj := SeriesObj{ID: name, Name: name}
if membership.Index != nil {
obj.Sequence = strconv.FormatFloat(*membership.Index, 'f', -1, 64)
}
series = append(series, obj)
}
publishedYear := ""
if item.Year > 0 {
publishedYear = strconv.Itoa(item.Year)
}
genres := item.Genres
if genres == nil {
genres = []string{}
}
// silo has no item-level tags concept today; emit an empty array so
// clients that branch on tags[] don't see a null and crash.
tags := []string{}
publisher := ""
if len(item.Studios) > 0 {
publisher = strings.TrimSpace(item.Studios[0])
}
return Metadata{
Title: item.Title,
Authors: authors,
Narrators: narrators,
Series: series,
Description: item.Overview,
PublishedYear: publishedYear,
Publisher: publisher,
Genres: genres,
Tags: tags,
}
}
// siloItemToLibraryItemDetail converts a silo MediaItem + its media files into
// a full ABS LibraryItem with audio track details populated. Called by
// handleItem (single-item GET).
func siloItemToLibraryItemDetail(item *models.MediaItem, files []*models.MediaFile, lib AudiobookLibrary, baseURL string) LibraryItem {
base := siloItemToLibraryItem(item, lib, baseURL)
tracks := siloFilesToAudioTracks(item.ContentID, files, baseURL, "")
// Recompute duration from files if the item's Runtime is zero.
totalDuration := base.Media.Duration
if totalDuration == 0 {
for _, t := range tracks {
totalDuration += t.Duration
}
}
// Chapters from the first file that has them.
chapters := make([]ChapterABS, 0)
for _, f := range files {
if len(f.Chapters) > 0 {
for i, c := range f.Chapters {
chapters = append(chapters, ChapterABS{
ID: i,
Start: c.StartSeconds,
End: c.EndSeconds,
Title: c.Title,
})
}
break
}
}
base.Media.AudioFiles = tracks
base.Media.Tracks = tracks
base.Media.Chapters = chapters
base.Media.NumTracks = len(tracks)
base.Media.Duration = totalDuration
base.NumTracks = len(tracks)
return base
}
// siloFilesToAudioTracks converts silo MediaFile rows into ABS AudioTrack
// entries for the item-detail response. token may be empty (item-detail
// doesn't embed auth tokens; the ABS client initiates playback via /play).
func siloFilesToAudioTracks(contentID string, files []*models.MediaFile, baseURL, token string) []AudioTrack {
tracks := make([]AudioTrack, 0, len(files))
startOffset := float64(0)
nowMs := time.Now().UnixMilli()
for i, f := range files {
ino := trackInoFor(contentID, i)
ext := strings.ToLower(filepath.Ext(f.FilePath))
format := strings.TrimPrefix(ext, ".")
mimeType := audioContentType(ext)
if mimeType == "" {
mimeType = "audio/mpeg"
}
filename := filepath.Base(f.FilePath)
wireIndex := i + 1
contentURL := baseURL + "/abs/api/items/" + contentID + "/file/" + ino
if token != "" {
contentURL += "?token=" + token
}
duration := float64(f.Duration)
bitRate := f.Bitrate * 1000
if bitRate == 0 {
bitRate = 128000
}
channels := f.AudioChannels
if channels == 0 {
channels = 2
}
channelLayout := "stereo"
if channels > 2 {
channelLayout = "surround"
}
tracks = append(tracks, AudioTrack{
Index: wireIndex,
Ino: ino,
Metadata: &AudioTrackMetadata{
Filename: filename,
Ext: ext,
Path: f.FilePath,
RelPath: filename,
Size: f.FileSize,
MtimeMs: nowMs,
CtimeMs: nowMs,
BirthtimeMs: nowMs,
},
AddedAt: nowMs,
UpdatedAt: nowMs,
ManuallyVerified: false,
Exclude: false,
Format: format,
Duration: duration,
BitRate: bitRate,
Language: nil,
Codec: f.CodecAudio,
TimeBase: "1/14112000",
Channels: channels,
ChannelLayout: channelLayout,
EmbeddedCoverArt: nil,
MetaTags: map[string]string{},
MimeType: mimeType,
Title: filename,
StartOffset: startOffset,
ContentURL: contentURL,
})
startOffset += duration
}
return tracks
}
// slugify produces a stable ID-from-name, identical to the plugin's translate.go
// implementation so derived IDs round-trip consistently.
func slugify(name string) string {
var b strings.Builder
prevDash := true
for _, r := range strings.ToLower(name) {
switch {
case isLetterOrDigit(r):
b.WriteRune(r)
prevDash = false
default:
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
}
return strings.TrimRight(b.String(), "-")
}
func isLetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
}
// includeHas tests whether an "include" comma-separated query value contains
// the given key.
func includeHas(raw, want string) bool {
if raw == "" {
return false
}
for _, p := range strings.Split(raw, ",") {
if strings.EqualFold(strings.TrimSpace(p), want) {
return true
}
}
return false
}
@@ -0,0 +1,109 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestSiloItemToMetadata_AuthorsHaveIDs(t *testing.T) {
item := &models.MediaItem{
Title: "Test Book",
People: []models.ItemPerson{
{Person: models.Person{ID: 42, Name: "Stephen King"}, Kind: models.PersonKindAuthor},
{Person: models.Person{ID: 43, Name: "Audie Murphy"}, Kind: models.PersonKindNarrator},
},
}
m := siloItemToMetadata(item)
if len(m.Authors) != 1 {
t.Fatalf("authors len = %d, want 1", len(m.Authors))
}
if m.Authors[0].ID != "42" {
t.Errorf("author ID = %q, want %q", m.Authors[0].ID, "42")
}
if m.Authors[0].Name != "Stephen King" {
t.Errorf("author Name = %q, want %q", m.Authors[0].Name, "Stephen King")
}
}
func TestSiloItemToMetadata_SeriesComeFromAudiobookSeries(t *testing.T) {
seq := 3.5
item := &models.MediaItem{
Title: "Test Book",
Studios: []string{"Book Publisher"},
AudiobookSeries: []models.AudiobookSeriesMembership{{Name: "The Dark Tower", Index: &seq}},
}
m := siloItemToMetadata(item)
if len(m.Series) != 1 {
t.Fatalf("series len = %d, want 1", len(m.Series))
}
if m.Series[0].ID != "The Dark Tower" {
t.Errorf("series ID = %q, want %q", m.Series[0].ID, "The Dark Tower")
}
if m.Series[0].Name != "The Dark Tower" {
t.Errorf("series Name = %q, want %q", m.Series[0].Name, "The Dark Tower")
}
if m.Series[0].Sequence != "3.5" {
t.Errorf("series Sequence = %q, want %q", m.Series[0].Sequence, "3.5")
}
if m.Publisher != "Book Publisher" {
t.Errorf("publisher = %q, want %q", m.Publisher, "Book Publisher")
}
}
func TestSiloItemToMetadata_GenresEmptyArrayNotNil(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"} // Genres nil
m := siloItemToMetadata(item)
if m.Genres == nil {
t.Errorf("Genres is nil; want empty slice")
}
if len(m.Genres) != 0 {
t.Errorf("Genres len = %d, want 0", len(m.Genres))
}
}
func TestSiloItemToMetadata_TagsEmptyArrayNotNil(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"}
m := siloItemToMetadata(item)
if m.Tags == nil {
t.Errorf("Tags is nil; want empty slice")
}
}
func TestSiloItemToMetadata_NarratorsListed(t *testing.T) {
item := &models.MediaItem{
Title: "Test Book",
People: []models.ItemPerson{
{Person: models.Person{ID: 1, Name: "Narrator One"}, Kind: models.PersonKindNarrator},
{Person: models.Person{ID: 2, Name: "Narrator Two"}, Kind: models.PersonKindNarrator},
},
}
m := siloItemToMetadata(item)
if len(m.Narrators) != 2 {
t.Fatalf("narrators len = %d, want 2", len(m.Narrators))
}
if m.Narrators[0] != "Narrator One" || m.Narrators[1] != "Narrator Two" {
t.Errorf("narrators = %v, want [Narrator One Narrator Two]", m.Narrators)
}
}
// TestSiloItemToMetadata_JSONKeysAlwaysPresent guards the omitempty fix:
// 3rd-party clients branch on the presence of "genres" and "tags" keys
// even when the values are empty arrays. Removing omitempty from those
// fields means the keys serialize even when the slice is empty.
func TestSiloItemToMetadata_JSONKeysAlwaysPresent(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"} // no genres, no tags, no people
m := siloItemToMetadata(item)
out, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(out)
for _, key := range []string{`"genres":`, `"tags":`, `"authors":`, `"series":`, `"narrators":`} {
if !strings.Contains(s, key) {
t.Errorf("JSON missing required key %s; got %s", key, s)
}
}
}
@@ -0,0 +1,107 @@
package abs
import (
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func (h *Handler) handleListeningStats(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaybackSessionStore == nil {
writeJSON(w, http.StatusOK, statsToABS(Stats{}))
return
}
stats, err := h.deps.PlaybackSessionStore.AggregateStats(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs listening stats failed", "err", err, "user", a.UserID)
http.Error(w, "stats unavailable", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, statsToABS(stats))
}
func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaybackSessionStore == nil {
writeJSON(w, http.StatusOK, pagedEnvelope([]any{}, 0, 30, 0, "started_at", true, "", false, ""))
return
}
limit, page := readPagedQuery(r, 30)
sessions, total, err := h.deps.PlaybackSessionStore.ListClosedSessions(r.Context(), a.UserID, a.ProfileID, limit, page*limit)
if err != nil {
slog.Error("abs listening sessions failed", "err", err, "user", a.UserID)
http.Error(w, "sessions unavailable", http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(sessions))
for _, s := range sessions {
out = append(out, sessionToABS(s))
}
writeJSON(w, http.StatusOK, pagedEnvelope(out, total, limit, page, "started_at", true, "", false, ""))
}
func (h *Handler) handleListeningSessionDetail(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaybackSessionStore == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
sid := chi.URLParam(r, "sid")
sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid)
if err != nil || sess.UserID != a.UserID {
http.Error(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, sessionToABS(sess))
}
func statsToABS(s Stats) map[string]any {
dow := map[string]int{}
for i, sec := range s.DayOfWeek {
dow[strconv.Itoa(i)] = sec
}
days := make([]map[string]any, 0, len(s.Days))
for _, d := range s.Days {
days = append(days, map[string]any{"date": d.Date, "seconds": d.Seconds})
}
monthly := make([]map[string]any, 0, len(s.Monthly))
for _, m := range s.Monthly {
monthly = append(monthly, map[string]any{"month": m.Month, "seconds": m.Seconds})
}
return map[string]any{
"totalTime": s.TotalTime,
"items": s.Items,
"days": days,
"dayOfWeek": dow,
"monthly": monthly,
}
}
func sessionToABS(s ABSPlaybackSession) map[string]any {
out := map[string]any{
"id": s.ID,
"libraryItemId": s.ContentID,
"userId": s.UserID,
"timeListening": s.TimeListeningSeconds,
"currentTime": s.CurrentPositionSeconds,
}
if s.ClosedAt != nil {
out["closedAt"] = s.ClosedAt.UnixMilli()
}
return out
}
@@ -0,0 +1,104 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"testing"
)
type statsFakeStore struct {
fakePlaybackSessionStore
stats Stats
closed []ABSPlaybackSession
}
func (f *statsFakeStore) AggregateStats(_ context.Context, _, _ string) (Stats, error) {
return f.stats, nil
}
func (f *statsFakeStore) ListClosedSessions(_ context.Context, _, _ string, limit, offset int) ([]ABSPlaybackSession, int, error) {
total := len(f.closed)
if offset >= total {
return nil, total, nil
}
end := offset + limit
if end > total {
end = total
}
return f.closed[offset:end], total, nil
}
func TestStats_Aggregate_Ok(t *testing.T) {
fake := &statsFakeStore{
stats: Stats{TotalTime: 3600, Items: 4, DayOfWeek: [7]int{0, 1800, 0, 1800, 0, 0, 0}},
}
h := New(Dependencies{MediaStore: noopMediaStore{}, PlaybackSessionStore: fake})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/listening-stats", nil, nil, "1", "", h.handleListeningStats)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["totalTime"] != float64(3600) {
t.Errorf("totalTime = %v, want 3600", got["totalTime"])
}
if got["items"] != float64(4) {
t.Errorf("items = %v, want 4", got["items"])
}
dow, _ := got["dayOfWeek"].(map[string]any)
if dow["1"] != float64(1800) {
t.Errorf("dayOfWeek[1] = %v, want 1800", dow["1"])
}
}
func TestStats_Sessions_List_Paginated(t *testing.T) {
fake := &statsFakeStore{closed: []ABSPlaybackSession{
{ID: "s1", UserID: "1", ContentID: "book-1"},
{ID: "s2", UserID: "1", ContentID: "book-2"},
{ID: "s3", UserID: "1", ContentID: "book-3"},
}}
h := New(Dependencies{MediaStore: noopMediaStore{}, PlaybackSessionStore: fake})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/listening-sessions", nil, nil, "1", "", h.handleListeningSessions)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env["total"] != float64(3) {
t.Errorf("total = %v, want 3", env["total"])
}
results, _ := env["results"].([]any)
if len(results) != 3 {
t.Errorf("results len = %d, want 3", len(results))
}
}
func TestStats_Session_Detail_Owner(t *testing.T) {
fake := &statsFakeStore{}
_ = fake.InsertPlaybackSession(context.Background(), ABSPlaybackSession{ID: "s1", UserID: "1", ContentID: "book-1"})
h := New(Dependencies{MediaStore: noopMediaStore{}, PlaybackSessionStore: fake})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/listening-sessions/s1", map[string]string{"sid": "s1"}, nil, "1", "", h.handleListeningSessionDetail)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["id"] != "s1" {
t.Errorf("id = %v", got["id"])
}
}
func TestStats_Session_Detail_NonOwner_404(t *testing.T) {
fake := &statsFakeStore{}
_ = fake.InsertPlaybackSession(context.Background(), ABSPlaybackSession{ID: "s1", UserID: "1", ContentID: "book-1"})
h := New(Dependencies{MediaStore: noopMediaStore{}, PlaybackSessionStore: fake})
rec := dispatchABSWithParams(http.MethodGet, "/api/me/listening-sessions/s1", map[string]string{"sid": "s1"}, nil, "2", "", h.handleListeningSessionDetail)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
+536
View File
@@ -0,0 +1,536 @@
package abs
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/auth"
)
// ErrNotFound is returned by TokenStore when a JTI is absent.
var ErrNotFound = errors.New("abs token not found")
// handleLogin mints ABS access + refresh JWTs for the caller.
//
// Login always validates body credentials. Public ABS listeners cannot safely
// trust user/profile headers because clients can spoof them directly.
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
h.handleStandaloneLogin(w, r)
}
// handleStandaloneLogin validates body-credential login. It checks whether
// standalone login is enabled, enforces the per-IP rate limit, decodes the JSON
// body, and calls CredValidator.
func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) {
if h.deps.Config == nil {
http.Error(w, "config not available", http.StatusServiceUnavailable)
return
}
enabled, err := h.deps.Config.StandaloneLoginEnabled(r.Context())
if err != nil {
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
if !enabled {
http.Error(w, "standalone login is disabled on this server", http.StatusUnauthorized)
return
}
if h.deps.CredValidator == nil {
http.Error(w, "standalone login is unavailable in this deployment", http.StatusServiceUnavailable)
return
}
ip := clientIP(r)
if !h.deps.LoginLimiter.allow(ip) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many login attempts; try again shortly", http.StatusTooManyRequests)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.Username) == "" || body.Password == "" {
http.Error(w, "username and password are required", http.StatusUnauthorized)
return
}
userID, profileID, displayName, err := h.deps.CredValidator.Validate(r.Context(), body.Username, body.Password)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) {
http.Error(w, "invalid username or password", http.StatusUnauthorized)
} else {
slog.Error("abs login: cred validator failed", "username", body.Username, "err", err)
http.Error(w, "login service unavailable", http.StatusServiceUnavailable)
}
return
}
// If the validator didn't return a displayName, derive it from the username
// (the profile portion after '#', or the whole username).
if displayName == "" {
displayName = body.Username
if i := strings.LastIndexByte(displayName, '#'); i >= 0 && i < len(displayName)-1 {
displayName = displayName[i+1:]
}
}
slog.Debug("abs standalone login: validator OK",
"username", body.Username, "user_id", userID, "profile_id", profileID)
h.completeLogin(w, r, userID, profileID, displayName)
}
// completeLogin mints ABS access + refresh JWTs for the validated user and
// writes the login response.
func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, profileID, displayName string) {
if h.deps.Config == nil {
http.Error(w, "config not available", http.StatusServiceUnavailable)
return
}
if h.deps.TokenStore == nil {
http.Error(w, "token store not available", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
http.Error(w, "jwt secret unavailable", http.StatusInternalServerError)
return
}
accessTTL, err := h.deps.Config.AccessTTL(r.Context())
if err != nil || accessTTL == 0 {
accessTTL = 24 * time.Hour
}
refreshTTL, err := h.deps.Config.RefreshTTL(r.Context())
if err != nil || refreshTTL == 0 {
refreshTTL = 30 * 24 * time.Hour
}
accessJTI := ulid.Make().String()
refreshJTI := ulid.Make().String()
access, err := IssueAccessToken(secret, userID, profileID, accessJTI, accessTTL)
if err != nil {
http.Error(w, "mint access token: "+err.Error(), http.StatusInternalServerError)
return
}
refresh, err := IssueRefreshToken(secret, userID, profileID, refreshJTI, refreshTTL)
if err != nil {
http.Error(w, "mint refresh token: "+err.Error(), http.StatusInternalServerError)
return
}
now := time.Now()
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: accessJTI,
UserID: userID,
ProfileID: profileID,
Type: "access",
JTI: accessJTI,
ExpiresAt: now.Add(accessTTL),
}); err != nil {
http.Error(w, "persist access token: "+err.Error(), http.StatusInternalServerError)
return
}
// Refresh-token insert must also succeed: a client that receives a refresh
// token whose JTI isn't in the store will fail on its first use (the
// bearer middleware looks up by JTI), forcing an interactive re-login.
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: refreshJTI,
UserID: userID,
ProfileID: profileID,
Type: "refresh",
JTI: refreshJTI,
ExpiresAt: now.Add(refreshTTL),
}); err != nil {
http.Error(w, "persist refresh token: "+err.Error(), http.StatusInternalServerError)
return
}
slog.Debug("abs completeLogin: tokens persisted",
"user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI)
writeJSON(w, http.StatusOK, h.loginEnvelope(r, now, userID, displayName, access, refresh))
}
// handleABSPing — GET /ping (mounted also as /healthcheck). Wire shape
// matches the continuum-plugin-audiobooks implementation exactly: clients
// use this to validate the URL before showing the login form, and any
// other shape causes the official mobile app to reject the server.
func (h *Handler) handleABSPing(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"server": "audiobookshelf",
"version": ServerVersion,
"pong": true,
})
}
// handleABSInit — GET /init. Real ABS first-run detection probe.
func (h *Handler) handleABSInit(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"isInit": true})
}
// handleABSStatus — GET /status. Mobile clients call this on every
// connection to confirm the server is an ABS install and pull a few
// global flags. Matches the plugin shape exactly (key order intentional).
func (h *Handler) handleABSStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"isInit": true,
"language": "en-us",
"app": "audiobookshelf",
"serverVersion": ServerVersion,
})
}
// loginEnvelope builds the response body shared by /login and /authorize.
// Both endpoints must return the identical shape so the iOS client's
// resume-on-launch flow validates the same way as fresh login.
// accessToken/refreshToken may be empty for /authorize (client already has
// them); in that case the top-level fields are still included as empty
// strings so the JSON shape stays stable.
//
// `now` is threaded in from the caller so the user.lastSeen/createdAt
// timestamps share a single instant with the token ExpiresAt the caller
// persisted — avoids two near-simultaneous time.Now() calls drifting apart.
func (h *Handler) loginEnvelope(
r *http.Request,
now time.Time,
userID, displayName, accessToken, refreshToken string,
) map[string]any {
// displayName falls back to userID when the validator didn't supply one.
// ABS clients require a non-empty username on the user envelope.
name := displayName
if name == "" {
name = userID
}
libraryMaps := make([]map[string]any, 0)
defaultLibraryID := VirtualLibraryID
access, _, _ := h.accessFilterFromRequest(r)
libs, _ := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
for i, lib := range libs {
if i == 0 {
defaultLibraryID = audiobookLibraryID(lib)
}
libraryMaps = append(libraryMaps, audiobookLibraryMap(lib))
}
nowMs := now.UnixMilli()
user := map[string]any{
"id": userID,
"username": name,
"type": "user",
"defaultLibraryId": defaultLibraryID,
"librariesAccessible": []any{},
"itemTagsAccessible": []any{},
"itemTagsSelected": []any{},
"mediaProgress": []any{},
"bookmarks": []any{},
"seriesHideFromContinueListening": []any{},
"isOldToken": false,
"token": accessToken,
"lastSeen": nowMs,
"createdAt": nowMs,
"permissions": map[string]any{
"download": true,
"update": true,
"delete": true,
"upload": true,
"accessAllLibraries": true,
"accessAllTags": true,
"accessExplicitContent": true,
"selectedTagsNotAccessible": false,
},
}
// x-return-tokens opt-in: when set, embed token pair on user object too
// (some clients read from the user envelope, others from the top level).
if strings.EqualFold(r.Header.Get("x-return-tokens"), "true") {
user["accessToken"] = accessToken
user["refreshToken"] = refreshToken
}
serverSettings := map[string]any{
"id": "server-settings",
"version": ServerVersion,
"buildNumber": 1,
"language": "en-us",
"dateFormat": "MM/dd/yyyy",
"timeFormat": "HH:mm",
"timeZone": "UTC",
"coverAspectRatio": 1,
"storeCoverWithItem": false,
"storeMetadataWithItem": false,
"metadataFileFormat": "json",
"scannerDisableWatcher": true,
"scannerParseSubtitle": false,
"scannerFindCovers": false,
"scannerCoverProvider": "google",
"scannerPreferMatchedMetadata": false,
"scannerPreferOverdriveMediaMarker": false,
"sortingIgnorePrefix": false,
"sortingPrefixes": []string{"the", "a"},
"chromecastEnabled": false,
"enableEReader": false,
"dateString": "",
"logLevel": 1,
"version_id": ServerVersion,
"sessionTimeout": 0,
"backupSchedule": false,
"backupsToKeep": 2,
"maxBackupSize": 1,
"loggerDailyLogsToKeep": 7,
"loggerScannerLogsToKeep": 2,
"homeBookshelfView": 1,
"bookshelfView": 1,
"podcastEpisodeSchedule": "0 * * * *",
"sortingIgnorePrefixesValue": "",
"allowIframe": false,
"authActiveAuthMethods": []string{"local"},
}
return map[string]any{
"user": user,
"userDefaultLibraryId": defaultLibraryID,
"serverSettings": serverSettings,
"Source": "silo",
"ereaderDevices": []any{},
"libraries": libraryMaps,
"accessToken": accessToken,
"refreshToken": refreshToken,
}
}
// handleABSAuthorize — POST /api/authorize. Real ABS uses this to
// validate a bearer token and re-mint the login envelope so the client
// can resume without retyping credentials. Mounted inside the bearerAuth
// group so it inherits the same token validation.
//
// The response shape is the FULL login envelope — same fields, same
// ordering — not a `{"user": ...}` wrapper. Mirrors the
// continuum-plugin-audiobooks handleAuthorize verbatim.
func (h *Handler) handleABSAuthorize(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Mint a NEW access token. ABS v2.26.0+ clients check whether the
// stored `serverConfig.token` equals the `user.token` echoed by
// /authorize; if equal they assume the server is still on the
// pre-v2.26 single-token shape and force re-login. Rotating the
// access JWT here keeps the client happy without changing the
// refresh-token contract. The OLD access JTI stays valid for its
// natural TTL (don't revoke — multiple devices share the same JTI
// via the share-on-login pattern, and a hard revoke would log
// other devices out).
access := a.Token
if h.deps.Config != nil && h.deps.TokenStore != nil {
if secret, err := h.deps.Config.JWTSecret(r.Context()); err == nil {
accessTTL, _ := h.deps.Config.AccessTTL(r.Context())
if accessTTL == 0 {
accessTTL = 24 * time.Hour
}
newJTI := ulid.Make().String()
if token, mintErr := IssueAccessToken(secret, a.UserID, a.ProfileID, newJTI, accessTTL); mintErr == nil {
if persistErr := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: newJTI,
UserID: a.UserID,
ProfileID: a.ProfileID,
Type: "access",
JTI: newJTI,
ExpiresAt: time.Now().Add(accessTTL),
}); persistErr == nil {
access = token
}
}
}
}
writeJSON(w, http.StatusOK, h.loginEnvelope(r, time.Now(), a.UserID, a.UserID, access, ""))
}
// handleRefresh — POST /auth/refresh
//
// Real ABS clients send the refresh token via x-refresh-token header with
// an empty body; legacy / 3rd-party clients send {refreshToken: "..."} in
// the JSON body. Accept either; header takes precedence when both are sent.
//
// Token rotation semantics:
// 1. Validate the refresh token signature + type.
// 2. Atomically revoke the old refresh JTI. A concurrent reuse loses here.
// 3. Mint a NEW access + refresh pair with fresh JTIs.
// 4. Persist both new JTIs.
// 5. Return {user:{accessToken, refreshToken}} AND top-level token fields
// for client compatibility — mainline app reads from user{}, third-party
// readers may read from the top level.
func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) {
refreshTok := strings.TrimSpace(r.Header.Get("x-refresh-token"))
if refreshTok == "" {
var p struct {
RefreshToken string `json:"refreshToken"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&p); err == nil {
refreshTok = p.RefreshToken
}
}
if refreshTok == "" {
http.Error(w, "refreshToken required", http.StatusBadRequest)
return
}
if h.deps.Config == nil || h.deps.TokenStore == nil {
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
claims, err := ParseToken(secret, refreshTok)
if err != nil || claims.Type != "refresh" {
claimsType := ""
if claims != nil {
claimsType = claims.Type
}
slog.Debug("abs refresh: parse/type failed", "err", err, "type", claimsType)
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
return
}
row, err := h.deps.TokenStore.RevokeTokenIfActive(r.Context(), claims.JTI)
if err != nil {
slog.Debug("abs refresh: jti revoke failed", "jti", claims.JTI, "err", err)
if errors.Is(err, ErrNotFound) {
http.Error(w, "refresh token revoked", http.StatusUnauthorized)
} else {
http.Error(w, "token rotation failed", http.StatusInternalServerError)
}
return
}
if row.UserID != "" && row.UserID != claims.UserID {
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
return
}
if row.ProfileID != "" && row.ProfileID != claims.ProfileID {
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
return
}
if row.Type != "" && row.Type != "refresh" {
http.Error(w, "invalid refresh token", http.StatusUnauthorized)
return
}
if !row.ExpiresAt.IsZero() && time.Now().After(row.ExpiresAt) {
http.Error(w, "refresh token expired", http.StatusUnauthorized)
return
}
accessTTL, err := h.deps.Config.AccessTTL(r.Context())
if err != nil || accessTTL == 0 {
accessTTL = 24 * time.Hour
}
refreshTTL, err := h.deps.Config.RefreshTTL(r.Context())
if err != nil || refreshTTL == 0 {
refreshTTL = 30 * 24 * time.Hour
}
newAccessJTI := ulid.Make().String()
newRefreshJTI := ulid.Make().String()
access, err := IssueAccessToken(secret, claims.UserID, claims.ProfileID, newAccessJTI, accessTTL)
if err != nil {
slog.Error("abs refresh: mint access failed", "user", claims.UserID, "err", err)
http.Error(w, "token mint failed", http.StatusInternalServerError)
return
}
refresh, err := IssueRefreshToken(secret, claims.UserID, claims.ProfileID, newRefreshJTI, refreshTTL)
if err != nil {
slog.Error("abs refresh: mint refresh failed", "user", claims.UserID, "err", err)
http.Error(w, "token mint failed", http.StatusInternalServerError)
return
}
now := time.Now()
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: newAccessJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
Type: "access", JTI: newAccessJTI, ExpiresAt: now.Add(accessTTL),
}); err != nil {
slog.Error("abs refresh: persist access failed", "user", claims.UserID, "jti", newAccessJTI, "err", err)
http.Error(w, "token persist failed", http.StatusInternalServerError)
return
}
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
ID: newRefreshJTI, UserID: claims.UserID, ProfileID: claims.ProfileID,
Type: "refresh", JTI: newRefreshJTI, ExpiresAt: now.Add(refreshTTL),
}); err != nil {
slog.Error("abs refresh: persist refresh failed", "user", claims.UserID, "jti", newRefreshJTI, "err", err)
http.Error(w, "token persist failed", http.StatusInternalServerError)
return
}
slog.Debug("abs refresh: rotated", "user", claims.UserID,
"old_jti", claims.JTI, "new_access_jti", newAccessJTI, "new_refresh_jti", newRefreshJTI)
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": claims.UserID,
"accessToken": access,
"refreshToken": refresh,
},
"accessToken": access,
"refreshToken": refresh,
})
}
// handleLogout — POST /logout (and /api/logout, /abs/api/logout, /abs/api/auth/logout)
//
// Mounted OUTSIDE the bearerAuth group so a client whose access token has
// expired — the most common "I want to sign out" moment — can still revoke
// their JTI without first re-authenticating. The handler parses the bearer
// itself, attempts JTI revoke if parseable, and ALWAYS returns 204. Mirrors
// continuum-plugin-audiobooks/internal/abs/handler.go:handleLogout.
//
// Logout invalidates every active ABS access/refresh token for the presented
// user profile so a signed-out client cannot silently mint a new access token
// with its saved refresh token.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
defer w.WriteHeader(http.StatusNoContent)
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
raw = r.URL.Query().Get("token")
}
if raw == "" || h.deps.TokenStore == nil || h.deps.Config == nil {
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
slog.Debug("abs logout: jwt secret fetch failed", "err", err)
return
}
claims, err := ParseToken(secret, raw)
if err != nil || claims.JTI == "" {
slog.Debug("abs logout: parse failed", "err", err)
return
}
if err := h.deps.TokenStore.RevokeTokensForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil {
slog.Warn("abs logout: revoke principal tokens failed", "jti", claims.JTI, "user", claims.UserID, "err", err)
return
}
if h.deps.PlaybackSessionStore != nil {
if err := h.deps.PlaybackSessionStore.CloseOpenSessionsForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil {
slog.Warn("abs logout: close sessions failed", "user", claims.UserID, "profile", claims.ProfileID, "err", err)
}
}
slog.Debug("abs logout: revoked principal tokens", "jti", claims.JTI, "user", claims.UserID)
}
@@ -0,0 +1,147 @@
package abs
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestLoginEnvelope_HasRequiredKeys marshals the response and asserts every
// field the real ABS iOS client expects is present. Guards against silent
// regressions on the login/authorize envelope shape — iOS degrades to a
// half-broken mode when any of these are missing.
func TestLoginEnvelope_HasRequiredKeys(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt")
body, err := json.Marshal(env)
if err != nil {
t.Fatalf("marshal: %v", err)
}
js := string(body)
topLevel := []string{
`"user":`,
`"userDefaultLibraryId":`,
`"serverSettings":`,
`"Source":`,
`"ereaderDevices":`,
`"libraries":`,
`"accessToken":`,
`"refreshToken":`,
}
for _, key := range topLevel {
if !strings.Contains(js, key) {
t.Errorf("top-level missing %s", key)
}
}
user, ok := env["user"].(map[string]any)
if !ok {
t.Fatalf("user is not a map: %T", env["user"])
}
userKeys := []string{
"id", "username", "type", "defaultLibraryId",
"librariesAccessible", "itemTagsAccessible", "itemTagsSelected",
"mediaProgress", "bookmarks", "seriesHideFromContinueListening",
"isOldToken", "token", "lastSeen", "createdAt", "permissions",
}
for _, k := range userKeys {
if _, present := user[k]; !present {
t.Errorf("user missing key %q", k)
}
}
perms, ok := user["permissions"].(map[string]any)
if !ok {
t.Fatalf("user.permissions is not a map: %T", user["permissions"])
}
permKeys := []string{
"download", "update", "delete", "upload",
"accessAllLibraries", "accessAllTags", "accessExplicitContent",
"selectedTagsNotAccessible",
}
for _, k := range permKeys {
if _, present := perms[k]; !present {
t.Errorf("permissions missing key %q", k)
}
}
settings, ok := env["serverSettings"].(map[string]any)
if !ok {
t.Fatalf("serverSettings is not a map: %T", env["serverSettings"])
}
settingsKeys := []string{
"id", "version", "buildNumber", "language", "scannerDisableWatcher",
"sortingPrefixes", "chromecastEnabled", "authActiveAuthMethods",
}
for _, k := range settingsKeys {
if _, present := settings[k]; !present {
t.Errorf("serverSettings missing key %q", k)
}
}
if user["token"] != "access.jwt" {
t.Errorf("user.token = %v, want access.jwt", user["token"])
}
if env["accessToken"] != "access.jwt" {
t.Errorf("accessToken = %v, want access.jwt", env["accessToken"])
}
if env["refreshToken"] != "refresh.jwt" {
t.Errorf("refreshToken = %v, want refresh.jwt", env["refreshToken"])
}
}
// TestLoginEnvelope_XReturnTokens_SurfacesOnUser covers the opt-in header:
// when x-return-tokens: true is set, the user object MUST include
// accessToken/refreshToken (some clients read from the user envelope rather
// than the top level).
func TestLoginEnvelope_XReturnTokens_SurfacesOnUser(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.Header.Set("x-return-tokens", "true")
env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt")
user, ok := env["user"].(map[string]any)
if !ok {
t.Fatalf("user is not a map: %T", env["user"])
}
if user["accessToken"] != "access.jwt" {
t.Errorf("user.accessToken = %v, want access.jwt (x-return-tokens not honored)", user["accessToken"])
}
if user["refreshToken"] != "refresh.jwt" {
t.Errorf("user.refreshToken = %v, want refresh.jwt (x-return-tokens not honored)", user["refreshToken"])
}
}
// TestLoginEnvelope_NoXReturnTokens_OmitsFromUser is the inverse: without the
// header, user object should NOT carry the duplicated tokens (top-level only).
func TestLoginEnvelope_NoXReturnTokens_OmitsFromUser(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt")
user, _ := env["user"].(map[string]any)
if _, present := user["accessToken"]; present {
t.Errorf("user.accessToken should not be set without x-return-tokens header")
}
}
// TestLoginEnvelope_DisplayNameFallsBackToUserID covers the empty-name path:
// the validator may return "" for displayName; the envelope must still emit a
// username (falling back to userID).
func TestLoginEnvelope_DisplayNameFallsBackToUserID(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/login", nil)
env := h.loginEnvelope(req, time.Now(),"user-42", "", "a", "r")
user, _ := env["user"].(map[string]any)
if user["username"] != "user-42" {
t.Errorf("username = %v, want user-42 (fallback)", user["username"])
}
}
@@ -0,0 +1,128 @@
package abs
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// newLogoutTestHandler builds a handler with TokenStore + Config + MediaStore
// wired so handleLogout can parse the bearer locally (no bearerAuth middleware
// runs in front of /logout — that's the whole point of the placement fix).
func newLogoutTestHandler(t *testing.T) (*Handler, *memTokenStore, *staticConfig) {
t.Helper()
store := newMemTokenStore()
cfg := &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}
h := New(Dependencies{
Config: cfg,
TokenStore: store,
MediaStore: noopMediaStore{},
})
return h, store, cfg
}
// mintAndPersistAccess mints an access JWT, persists its JTI, and returns the
// raw token + JTI. Mirrors mintAndPersistRefresh in login_refresh_test.go.
func mintAndPersistAccess(t *testing.T, store *memTokenStore, cfg *staticConfig, userID, jti string) string {
t.Helper()
access, err := IssueAccessToken(cfg.secret, userID, "", jti, time.Hour)
if err != nil {
t.Fatalf("mint access: %v", err)
}
if err := store.InsertToken(context.Background(), ABSToken{
ID: jti, UserID: userID, JTI: jti, ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("insert: %v", err)
}
return access
}
func TestHandleLogout_RevokesJTIAndReturns204(t *testing.T) {
h, store, cfg := newLogoutTestHandler(t)
jti := "logout-test-jti"
access := mintAndPersistAccess(t, store, cfg, "1", jti)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer "+access)
rec := httptest.NewRecorder()
h.handleLogout(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
tok, _ := store.GetTokenByJTI(context.Background(), jti)
if tok.RevokedAt == nil {
t.Errorf("JTI %s was not revoked", jti)
}
}
// TestHandleLogout_NoBearer_204 covers the "client called sign-out with no
// token" path — must still 204 (logout is idempotent / fire-and-forget).
func TestHandleLogout_NoBearer_204(t *testing.T) {
h, _, _ := newLogoutTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
rec := httptest.NewRecorder()
h.handleLogout(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
}
// TestHandleLogout_GarbageBearer_204 covers an unparseable token: must still
// 204 (we never want sign-out to error the client out).
func TestHandleLogout_GarbageBearer_204(t *testing.T) {
h, _, _ := newLogoutTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
rec := httptest.NewRecorder()
h.handleLogout(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
}
// TestHandleLogout_WrongSignature_204 covers a token signed by a different
// secret (attacker token, restored from backup, etc.): must 204 and NOT
// revoke (signature mismatch means we can't trust the JTI claim).
func TestHandleLogout_WrongSignature_204(t *testing.T) {
h, store, _ := newLogoutTestHandler(t)
jti := "victim-jti"
_ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "victim", JTI: jti})
bogus, err := IssueAccessToken([]byte("attacker-secret"), "victim", "", jti, time.Hour)
if err != nil {
t.Fatalf("mint bogus: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer "+bogus)
rec := httptest.NewRecorder()
h.handleLogout(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
tok, _ := store.GetTokenByJTI(context.Background(), jti)
if tok.RevokedAt != nil {
t.Errorf("victim JTI was revoked via wrong-signature token; want untouched")
}
}
// TestHandleLogout_IsIdempotent covers repeat sign-outs against the same token:
// must 204 each time, JTI revoked-once and not re-revoked-or-errored.
func TestHandleLogout_IsIdempotent(t *testing.T) {
h, store, cfg := newLogoutTestHandler(t)
jti := "idem-jti"
access := mintAndPersistAccess(t, store, cfg, "1", jti)
for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Authorization", "Bearer "+access)
rec := httptest.NewRecorder()
h.handleLogout(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("iter %d: status = %d, want 204", i, rec.Code)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package abs
import (
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
// loginLimitBurst caps the number of body-creds /login attempts a single
// source IP can make in quick succession. The token bucket refills at
// loginLimitPerToken (10/min ≈ one every 6s), so a bursty client can spend
// the burst and then must wait. Tuned to be invisible to legitimate listeners
// (who attempt login once and succeed) while making credential-stuffing
// expensive.
const (
loginLimitBurst = 10
loginLimitPerToken = 6 * time.Second
loginLimitIdle = 10 * time.Minute
loginLimitGCEvery = 5 * time.Minute
)
type loginLimiterEntry struct {
limiter *rate.Limiter
last time.Time
}
// LoginLimiter is a process-local per-IP rate limiter for the standalone-port
// body-creds login path. The header-authenticated path is never gated here —
// that traffic comes from the trusted silo host proxy.
//
// Construct one per process (in server wiring) and inject via
// Dependencies.LoginLimiter. Constructing one per Handler would leak a
// janitor goroutine on every reconfigure.
type LoginLimiter struct {
mu sync.Mutex
buckets map[string]*loginLimiterEntry
stopCh chan struct{}
}
// NewLoginLimiter builds a limiter and starts its background janitor. The
// janitor exits when Stop() is called.
func NewLoginLimiter() *LoginLimiter {
l := &LoginLimiter{
buckets: make(map[string]*loginLimiterEntry),
stopCh: make(chan struct{}),
}
go l.janitor()
return l
}
// Stop terminates the janitor goroutine. Safe to call once.
func (l *LoginLimiter) Stop() { close(l.stopCh) }
func (l *LoginLimiter) allow(key string) bool {
if key == "" {
return true
}
l.mu.Lock()
e, ok := l.buckets[key]
if !ok {
e = &loginLimiterEntry{
limiter: rate.NewLimiter(rate.Every(loginLimitPerToken), loginLimitBurst),
}
l.buckets[key] = e
}
e.last = time.Now()
lim := e.limiter
l.mu.Unlock()
return lim.Allow()
}
func (l *LoginLimiter) janitor() {
ticker := time.NewTicker(loginLimitGCEvery)
defer ticker.Stop()
for {
select {
case <-l.stopCh:
return
case <-ticker.C:
cutoff := time.Now().Add(-loginLimitIdle)
l.mu.Lock()
for k, e := range l.buckets {
if e.last.Before(cutoff) {
delete(l.buckets, k)
}
}
l.mu.Unlock()
}
}
}
// clientIP returns the rate-limit key for a request. The standalone listener
// is public, so spoofable forwarding headers are deliberately ignored.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
@@ -0,0 +1,164 @@
package abs
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// barrierStore wraps memTokenStore and gates the FIRST RevokeTokenIfActive
// call on `release` so both goroutines in the concurrent test can complete
// their initial GetTokenByJTI lookup before either revoke fires. Without
// this, the Go scheduler can serialize the test enough that goroutine B's
// GetTokenByJTI sees A's already-revoked old JTI and 401s — which is a
// real race-loser scenario in production but not the case the canonical's
// documented "both succeed" semantics describe.
type barrierStore struct {
*memTokenStore
release chan struct{}
gateOnce sync.Once
gateClosed chan struct{}
}
func (b *barrierStore) RevokeTokenIfActive(ctx context.Context, jti string) (ABSToken, error) {
b.gateOnce.Do(func() {
<-b.release
close(b.gateClosed)
})
return b.memTokenStore.RevokeTokenIfActive(ctx, jti)
}
func TestHandleRefresh_ConcurrentRotations_OnlyOneSucceeds(t *testing.T) {
cfg := &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}
mem := newMemTokenStore()
gate := &barrierStore{memTokenStore: mem, release: make(chan struct{}), gateClosed: make(chan struct{})}
h := New(Dependencies{Config: cfg, TokenStore: gate, MediaStore: noopMediaStore{}})
jti := "race-old-jti"
refresh, err := IssueRefreshToken(cfg.secret, "race-user", "", jti, 30*24*time.Hour)
if err != nil {
t.Fatalf("mint: %v", err)
}
if err := mem.InsertToken(context.Background(), ABSToken{
ID: jti, UserID: "race-user", JTI: jti, ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}); err != nil {
t.Fatalf("seed: %v", err)
}
type result struct {
code int
access string
}
results := make(chan result, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
access := ""
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err == nil {
if s, ok := resp["accessToken"].(string); ok {
access = s
}
}
results <- result{code: rec.Code, access: access}
}()
}
// Give both goroutines time to reach the atomic revoke barrier, then
// release the gate so one rotation wins and the other sees a revoked JTI.
time.Sleep(50 * time.Millisecond)
close(gate.release)
wg.Wait()
close(results)
tokens := map[string]bool{}
successes := 0
unauthorized := 0
for r := range results {
switch r.code {
case http.StatusOK:
successes++
case http.StatusUnauthorized:
unauthorized++
default:
t.Errorf("concurrent refresh got code %d; want 200 or 401", r.code)
}
if r.access != "" {
tokens[r.access] = true
}
}
if successes != 1 || unauthorized != 1 {
t.Errorf("got successes=%d unauthorized=%d; want 1 each", successes, unauthorized)
}
if len(tokens) != 1 {
t.Errorf("got %d distinct access tokens; want 1", len(tokens))
}
old, _ := mem.GetTokenByJTI(context.Background(), jti)
if old.RevokedAt == nil {
t.Errorf("old refresh JTI %s not revoked after concurrent rotations", jti)
}
}
// failingRevokeStore wraps memTokenStore but returns an error from
// RevokeTokenByJTI. Used to exercise the partial-failure path in
// handleRefresh: if the revoke of the old JTI errors, the new tokens have
// already been persisted but the old token also remains valid — the
// canonical accepts this trade rather than rolling back partial writes.
type failingRevokeStore struct{ *memTokenStore }
func (failingRevokeStore) RevokeTokenIfActive(context.Context, string) (ABSToken, error) {
return ABSToken{}, errors.New("revoke unavailable")
}
// TestHandleRefresh_RevokeFailure_OldTokenStillValid covers the documented
// partial-failure semantics: when revoke errors, handleRefresh returns 500
// and the OLD refresh JTI is left untouched (still valid). The client can
// retry with the same old token because no new tokens are persisted before
// the atomic revoke succeeds.
func TestHandleRefresh_RevokeFailure_OldTokenStillValid(t *testing.T) {
cfg := &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}
mem := newMemTokenStore()
store := failingRevokeStore{memTokenStore: mem}
h := New(Dependencies{
Config: cfg,
TokenStore: store,
MediaStore: noopMediaStore{},
})
jti := "old-refresh-jti"
refresh, err := IssueRefreshToken(cfg.secret, "u1", "", jti, 30*24*time.Hour)
if err != nil {
t.Fatalf("mint refresh: %v", err)
}
if err := mem.InsertToken(context.Background(), ABSToken{
ID: jti, UserID: "u1", JTI: jti, ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (revoke failure)", rec.Code)
}
// Old JTI must remain unrevoked so the client can retry the rotation.
old, _ := mem.GetTokenByJTI(context.Background(), jti)
if old.RevokedAt != nil {
t.Errorf("old refresh JTI was revoked despite RevokeTokenByJTI error")
}
}
@@ -0,0 +1,249 @@
package abs
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// noopMediaStore satisfies the MediaStore interface with empty returns.
// abs.New panics on a nil MediaStore, so handler tests that don't exercise
// catalog reads pass this to satisfy the contract.
type noopMediaStore struct{}
func (noopMediaStore) GetAudiobookByID(context.Context, string, catalog.AccessFilter) (*models.MediaItem, error) {
return nil, nil
}
func (noopMediaStore) ListAudiobooks(context.Context, int64, int, int, catalog.AccessFilter) ([]*models.MediaItem, int, error) {
return nil, 0, nil
}
func (noopMediaStore) GetMediaFiles(context.Context, string, catalog.AccessFilter) ([]*models.MediaFile, error) {
return nil, nil
}
func (noopMediaStore) GetMediaFileByID(context.Context, int) (*models.MediaFile, error) {
return nil, nil
}
func (noopMediaStore) ListAudiobookLibraries(context.Context, catalog.AccessFilter) ([]AudiobookLibrary, error) {
return nil, nil
}
func (noopMediaStore) SearchAudiobooks(context.Context, int64, string, int, catalog.AccessFilter) ([]*models.MediaItem, error) {
return nil, nil
}
func (noopMediaStore) ListContinueListening(context.Context, string, string, int64, int, catalog.AccessFilter) ([]*models.MediaItem, error) {
return nil, nil
}
func (noopMediaStore) ListRecentlyAdded(context.Context, int64, int, catalog.AccessFilter) ([]*models.MediaItem, error) {
return nil, nil
}
func (noopMediaStore) ListDiscover(context.Context, int64, int, catalog.AccessFilter) ([]*models.MediaItem, error) {
return nil, nil
}
func (noopMediaStore) ListLibraryAuthors(context.Context, int64, int, catalog.AccessFilter) ([]AuthorSummary, error) {
return nil, nil
}
func (noopMediaStore) ListLibrarySeries(context.Context, int64, int, catalog.AccessFilter) ([]SeriesSummary, error) {
return nil, nil
}
func (noopMediaStore) GetAuthorByID(context.Context, string, catalog.AccessFilter) (Author, error) {
return Author{}, ErrNotFound
}
func (noopMediaStore) GetSeriesByName(context.Context, string, catalog.AccessFilter) (Series, error) {
return Series{}, ErrNotFound
}
// memTokenStore is an in-memory TokenStore for handleRefresh tests.
type memTokenStore struct {
mu sync.Mutex
tokens map[string]ABSToken
}
func newMemTokenStore() *memTokenStore { return &memTokenStore{tokens: map[string]ABSToken{}} }
func (m *memTokenStore) InsertToken(_ context.Context, tok ABSToken) error {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens[tok.JTI] = tok
return nil
}
func (m *memTokenStore) GetTokenByJTI(_ context.Context, jti string) (ABSToken, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[jti]
if !ok {
return ABSToken{}, ErrNotFound
}
return t, nil
}
func (m *memTokenStore) RevokeTokenByJTI(_ context.Context, jti string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[jti]
if !ok {
return nil
}
now := time.Now()
t.RevokedAt = &now
m.tokens[jti] = t
return nil
}
func (m *memTokenStore) RevokeTokenIfActive(_ context.Context, jti string) (ABSToken, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tokens[jti]
if !ok || t.RevokedAt != nil {
return ABSToken{}, ErrNotFound
}
now := time.Now()
t.RevokedAt = &now
m.tokens[jti] = t
return t, nil
}
func (m *memTokenStore) RevokeTokensForPrincipal(_ context.Context, userID, profileID string) error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for jti, tok := range m.tokens {
if tok.UserID == userID && tok.ProfileID == profileID && tok.RevokedAt == nil {
tok.RevokedAt = &now
m.tokens[jti] = tok
}
}
return nil
}
func (m *memTokenStore) TouchToken(_ context.Context, _ string) error { return nil }
// staticConfig satisfies ConfigProvider with fixed values.
type staticConfig struct{ secret []byte }
func (s *staticConfig) JWTSecret(_ context.Context) ([]byte, error) { return s.secret, nil }
func (s *staticConfig) AccessTTL(_ context.Context) (time.Duration, error) {
return 24 * time.Hour, nil
}
func (s *staticConfig) RefreshTTL(_ context.Context) (time.Duration, error) {
return 30 * 24 * time.Hour, nil
}
func (s *staticConfig) StandaloneLoginEnabled(_ context.Context) (bool, error) { return true, nil }
func newRefreshTestHandler(t *testing.T) (*Handler, *memTokenStore, *staticConfig) {
t.Helper()
store := newMemTokenStore()
cfg := &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}
h := New(Dependencies{
Config: cfg,
TokenStore: store,
MediaStore: noopMediaStore{},
})
return h, store, cfg
}
func mintAndPersistRefresh(t *testing.T, store *memTokenStore, cfg *staticConfig, userID string) (string, string) {
t.Helper()
jti := "test-refresh-jti-" + userID
refresh, err := IssueRefreshToken(cfg.secret, userID, "", jti, 30*24*time.Hour)
if err != nil {
t.Fatalf("mint refresh: %v", err)
}
if err := store.InsertToken(context.Background(), ABSToken{
ID: jti, UserID: userID, JTI: jti, ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}); err != nil {
t.Fatalf("insert: %v", err)
}
return refresh, jti
}
func TestHandleRefresh_HeaderToken_RotatesAndReturnsBothForms(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, oldJTI := mintAndPersistRefresh(t, store, cfg, "42")
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["accessToken"] == "" || resp["accessToken"] == nil {
t.Errorf("top-level accessToken missing")
}
user, ok := resp["user"].(map[string]any)
if !ok {
t.Fatalf("user object missing")
}
if user["accessToken"] == "" || user["accessToken"] == nil {
t.Errorf("user.accessToken missing")
}
old, _ := store.GetTokenByJTI(context.Background(), oldJTI)
if old.RevokedAt == nil {
t.Errorf("old refresh JTI %s was not revoked", oldJTI)
}
}
func TestHandleRefresh_BodyToken_Works(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, _ := mintAndPersistRefresh(t, store, cfg, "1")
body := bytes.NewBufferString(`{"refreshToken":"` + refresh + `"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleRefresh_NoToken_400(t *testing.T) {
h, _, _ := newRefreshTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleRefresh_AccessTokenRejected(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
jti := "an-access-jti"
access, _ := IssueAccessToken(cfg.secret, "9", "", jti, time.Hour)
_ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "9", JTI: jti, ExpiresAt: time.Now().Add(time.Hour)})
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", strings.NewReader(""))
req.Header.Set("x-refresh-token", access)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleRefresh_RevokedTokenRejected(t *testing.T) {
h, store, cfg := newRefreshTestHandler(t)
refresh, oldJTI := mintAndPersistRefresh(t, store, cfg, "7")
_ = store.RevokeTokenByJTI(context.Background(), oldJTI)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.Header.Set("x-refresh-token", refresh)
rec := httptest.NewRecorder()
h.handleRefresh(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
+67
View File
@@ -0,0 +1,67 @@
package abs
import (
"net/http"
"strconv"
"strings"
)
// handleMe — GET /abs/api/me (and /api/me)
//
// Minimal {id, username, defaultLibraryId} envelope — matches the
// continuum-plugin-audiobooks shape exactly. ABS clients already
// have the full user object from /login and /authorize; /me is just
// a session-resume probe in real-ABS, so returning the rich user
// envelope here is unnecessary and can confuse clients that pattern-
// match on the minimal shape.
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
defaultLibID := VirtualLibraryID
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
if err == nil && len(libs) > 0 {
defaultLibID = audiobookLibraryID(libs[0])
}
writeJSON(w, http.StatusOK, map[string]any{
"id": a.UserID,
"username": a.UserID,
"defaultLibraryId": defaultLibID,
})
}
// audiobookLibraryID returns the ABS-wire library ID string for an
// AudiobookLibrary. Numeric IDs are formatted as decimal strings; when
// the ID is 0 we fall back to the virtual constant.
func audiobookLibraryID(lib AudiobookLibrary) string {
if lib.ID > 0 {
return strconv.FormatInt(lib.ID, 10)
}
return VirtualLibraryID
}
// audiobookLibraryMap builds the minimal ABS library object shared by
// /libraries (list) and /libraries/{id} (detail). Plugin parity:
// {id, name, mediaType} only — the extra folders/displayOrder/icon
// fields some servers emit are ignored by real ABS clients and adding
// them risks behaviour drift.
func audiobookLibraryMap(lib AudiobookLibrary) map[string]any {
name := strings.TrimSpace(lib.Name)
if name == "" {
name = VirtualLibraryName
}
return map[string]any{
"id": audiobookLibraryID(lib),
"name": name,
"mediaType": LibraryMediaType,
}
}
+100
View File
@@ -0,0 +1,100 @@
package abs
import "strings"
// MinifiedLibraryItem mirrors real-ABS's `minified=1` response shape. The
// big-ticket changes from the full LibraryItem: media.metadata.authors and
// media.metadata.series arrays are omitted; the metadata block grows flat
// authorName / authorNameLF / seriesName / seriesSequence fields built from
// the same source data; chapters and audioFiles are dropped.
//
// Real ABS clients sniff for these fields by name. Emitting the full shape
// for a minified request works (clients ignore extra fields) but is wasteful
// on the wire when a client is paging through hundreds of items. Emitting
// the minified shape for a full request silently breaks detail pages.
type MinifiedLibraryItem struct {
ID string `json:"id"`
LibraryID string `json:"libraryId"`
FolderID string `json:"folderId"`
MediaType string `json:"mediaType"`
Media minifiedMedia `json:"media"`
NumTracks int `json:"numTracks,omitempty"`
AddedAt int64 `json:"addedAt"`
UpdatedAt int64 `json:"updatedAt"`
}
type minifiedMedia struct {
Metadata minifiedMetadata `json:"metadata"`
Duration float64 `json:"duration"`
CoverPath string `json:"coverPath"`
}
type minifiedMetadata struct {
Title string `json:"title"`
AuthorName string `json:"authorName"`
AuthorNameLF string `json:"authorNameLF"`
SeriesName string `json:"seriesName,omitempty"`
SeriesSequence string `json:"seriesSequence,omitempty"`
Narrators []string `json:"narrators,omitempty"`
PublishedYear string `json:"publishedYear,omitempty"`
}
// Minify projects a LibraryItem onto the minified shape. The original item
// is not mutated. authorName joins authors with ", "; authorNameLF inverts
// the standard "First Last" → "Last, First" form when there's a space, and
// joins multiple authors with " & " (matching the convention real ABS uses
// for shelf sort labels).
func Minify(item LibraryItem) MinifiedLibraryItem {
m := item.Media.Metadata
names := make([]string, 0, len(m.Authors))
lfNames := make([]string, 0, len(m.Authors))
for _, a := range m.Authors {
if a.Name == "" {
continue
}
names = append(names, a.Name)
lfNames = append(lfNames, lastFirst(a.Name))
}
seriesName, seriesSeq := "", ""
if len(m.Series) > 0 {
s := m.Series[0]
seriesName = s.Name
seriesSeq = s.Sequence
}
return MinifiedLibraryItem{
ID: item.ID,
LibraryID: item.LibraryID,
FolderID: item.FolderID,
MediaType: item.MediaType,
Media: minifiedMedia{
Metadata: minifiedMetadata{
Title: m.Title,
AuthorName: strings.Join(names, ", "),
AuthorNameLF: strings.Join(lfNames, " & "),
SeriesName: seriesName,
SeriesSequence: seriesSeq,
Narrators: m.Narrators,
PublishedYear: m.PublishedYear,
},
Duration: item.Media.Duration,
CoverPath: item.Media.CoverPath,
},
NumTracks: item.NumTracks,
AddedAt: item.AddedAt,
UpdatedAt: item.UpdatedAt,
}
}
// lastFirst flips a "First Middle Last" name into "Last, First Middle". For
// single-token names it returns them unchanged. Empty inputs return empty.
func lastFirst(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
last := strings.LastIndexByte(name, ' ')
if last <= 0 || last == len(name)-1 {
return name
}
return name[last+1:] + ", " + name[:last]
}
+528
View File
@@ -0,0 +1,528 @@
package abs
import (
"context"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/models"
)
// handlePlayStart handles POST /abs/api/items/{libraryItemId}/play.
//
// Real ABS clients hit this endpoint to start a playback session and get back
// a manifest of audio tracks with signed contentUrls. The response includes
// the full playbackSession shape the mobile player reads to seed the audio
// element: currentTime (resume position), audioTracks, mediaMetadata,
// libraryItem, chapters, etc.
//
// Silo's implementation:
// - Looks up the audiobook via MediaStore.GetAudiobookByID.
// - Loads all media files via MediaStore.GetMediaFiles (sorted by ID ASC).
// - Synthesises a ULID session ID (in-memory; no play-session table yet).
// - Builds contentUrls that point at the session-scoped public track route,
// avoiding bearer tokens in URLs while preserving ABS DirectPlay behavior.
// - Returns a JSON playbackSession matching the shape real ABS emits, with
// enough fields populated that the official mobile client plays without
// entering "spinner forever" mode.
func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "libraryItemId")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID, access)
if err != nil {
http.Error(w, "load files failed", http.StatusInternalServerError)
return
}
// The {ino} parameter used by handleFileStream is a 0-based file index, but
// we want iOS clients to resolve it via the stable MD5 derivation. Emit inos
// that handleFileStream can reverse without a database lookup.
baseURL := h.absBaseURL(r)
sessionID := ulid.Make().String()
// Persist the session row so subsequent PATCH /session/{sid} heartbeats
// and POST /session/{sid}/close can find it. Without this, the session
// ID is returned to the client but every sync/close lookup 404s.
if h.deps.PlaybackSessionStore != nil {
sess := ABSPlaybackSession{
ID: sessionID,
UserID: a.UserID,
ProfileID: a.ProfileID,
ContentID: contentID,
}
if len(files) > 0 {
fid := files[0].ID
sess.MediaFileID = &fid
}
if err := h.deps.PlaybackSessionStore.InsertPlaybackSession(r.Context(), sess); err != nil {
// Non-fatal: log but still return the manifest so the client
// can play. Heartbeat/close calls will fail with 404 until the
// next play_start lands cleanly.
slog.Warn("abs play: persist session failed",
"session_id", sessionID, "content_id", contentID, "err", err)
}
}
audioTracks := buildSiloAudioTracks(contentID, files, baseURL, sessionID)
totalDuration := float64(0)
for _, t := range audioTracks {
totalDuration += t.Duration
}
chapters := buildSiloChapters(files)
mediaMetadata := buildSiloPlayMediaMetadata(item)
libraryItem := buildSiloPlayLibraryItem(item, contentID, mediaMetadata, audioTracks, chapters, totalDuration, baseURL)
displayTitle := item.Title
displayAuthor := ""
if v, ok := mediaMetadata["authorName"].(string); ok {
displayAuthor = v
}
now := time.Now()
nowMs := now.UnixMilli()
dateStr := now.UTC().Format("2006-01-02")
dayOfWeek := now.UTC().Weekday().String()
// currentTime seeds the audio element's initial position so cross-device
// resume works. Lookup is best-effort: any error returns position 0,
// which is always correct for a first listen.
//
// Note: we deliberately do NOT emit a "progress" (0.0-1.0) field on the
// play session response. The canonical continuum-plugin handler omits it
// too — "progress" belongs to /me/progress responses, not playbackSession.
// The spec's Phase 0 row mentioning "currentTime AND progress fields" was
// over-specified; matching the canonical wire shape is the load-bearing
// requirement.
var currentTime float64
currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID)
if err != nil {
slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err)
// currentTime is already 0 on error path; safe to continue.
}
playbackSession := map[string]any{
"id": sessionID,
"userId": a.UserID,
"libraryId": VirtualLibraryID,
"libraryItemId": contentID,
"bookId": contentID,
"episodeId": nil,
"mediaType": LibraryMediaType,
"mediaMetadata": mediaMetadata,
"chapters": chapters,
"displayTitle": displayTitle,
"displayAuthor": displayAuthor,
"coverPath": baseURL + "/api/items/" + contentID + "/cover",
"duration": totalDuration,
"playMethod": 0, // DIRECTPLAY
"mediaPlayer": "exo-player",
"deviceInfo": map[string]any{
"deviceId": "unknown",
"manufacturer": "Unknown",
"model": "Unknown",
"sdkVersion": 0,
"clientVersion": "0.0.0",
},
"serverVersion": ServerVersion,
"date": dateStr,
"dayOfWeek": dayOfWeek,
"timeListening": 0,
"startTime": currentTime,
"currentTime": currentTime,
"startedAt": nowMs,
"updatedAt": nowMs,
"audioTracks": audioTracks,
"libraryItem": libraryItem,
}
writeJSON(w, http.StatusOK, playbackSession)
}
// buildSiloAudioTracks converts silo media_files into the ABS AudioTrack
// slice. Each track's contentUrl points at the short-lived session public
// track route so bearer tokens are never embedded in URLs.
func buildSiloAudioTracks(
contentID string,
files []*models.MediaFile,
baseURL string,
sessionID string,
) []AudioTrack {
tracks := make([]AudioTrack, 0, len(files))
startOffset := float64(0)
for i, f := range files {
ino := trackInoFor(contentID, i)
ext := strings.ToLower(filepath.Ext(f.FilePath))
format := strings.TrimPrefix(ext, ".")
mimeType := audioContentType(ext)
if mimeType == "" {
mimeType = "audio/mpeg"
}
filename := filepath.Base(f.FilePath)
// ABS wire index is 1-based to match the real server's convention.
// Our ino uses 0-based internally; handleFileStream resolves via ino.
wireIndex := i + 1
contentURL := baseURL + "/abs/public/session/" + sessionID + "/track/" + strconv.Itoa(wireIndex)
duration := float64(f.Duration) // f.Duration is in seconds (int)
var bitRate int
if f.Bitrate > 0 {
bitRate = f.Bitrate * 1000 // kbps → bps
} else {
bitRate = 128000
}
channels := f.AudioChannels
if channels == 0 {
channels = 2
}
codec := f.CodecAudio
channelLayout := "stereo"
if channels > 2 {
channelLayout = "surround"
}
nowMs := time.Now().UnixMilli()
track := AudioTrack{
Index: wireIndex,
Ino: ino,
Metadata: &AudioTrackMetadata{
Filename: filename,
Ext: ext,
Path: f.FilePath,
RelPath: filename,
Size: f.FileSize,
MtimeMs: 0,
CtimeMs: 0,
BirthtimeMs: 0,
},
AddedAt: nowMs,
UpdatedAt: nowMs,
TrackNumFromMeta: nil,
DiscNumFromMeta: nil,
TrackNumFromFilename: nil,
DiscNumFromFilename: nil,
ManuallyVerified: false,
Exclude: false,
Error: nil,
Format: format,
Duration: duration,
BitRate: bitRate,
Language: nil,
Codec: codec,
TimeBase: "1/14112000",
Channels: channels,
ChannelLayout: channelLayout,
Chapters: nil,
EmbeddedCoverArt: nil,
MetaTags: map[string]string{},
MimeType: mimeType,
Title: filename,
StartOffset: startOffset,
ContentURL: contentURL,
}
tracks = append(tracks, track)
startOffset += duration
}
return tracks
}
// buildSiloChapters extracts chapters from the first media file that has them.
// ABS expects chapters as a flat list spanning the whole book; for multi-file
// audiobooks we only use the first file's chapters (most single-file M4B
// audiobooks have embedded chapters; multi-MP3 sets rarely do).
func buildSiloChapters(files []*models.MediaFile) []map[string]any {
for _, f := range files {
if len(f.Chapters) == 0 {
continue
}
chapters := make([]map[string]any, 0, len(f.Chapters))
for i, c := range f.Chapters {
chapters = append(chapters, map[string]any{
"id": i,
"start": c.StartSeconds,
"end": c.EndSeconds,
"title": c.Title,
})
}
return chapters
}
return []map[string]any{}
}
// buildSiloPlayMediaMetadata builds the playbackSession.mediaMetadata object
// from a silo MediaItem. The mobile player reads this for the "Now Playing"
// widget and playback history; missing keys cause the audio loader to abort.
func buildSiloPlayMediaMetadata(item *models.MediaItem) map[string]any {
title := item.Title
authors := make([]map[string]any, 0)
authorNames := make([]string, 0)
narrators := make([]string, 0)
for _, p := range item.People {
switch p.Kind {
case models.PersonKindAuthor:
authors = append(authors, map[string]any{
"id": strconv.FormatInt(p.ID, 10),
"name": p.Name,
})
authorNames = append(authorNames, p.Name)
case models.PersonKindNarrator:
narrators = append(narrators, p.Name)
}
}
authorName := strings.Join(authorNames, ", ")
lastFirsts := make([]string, len(authorNames))
for i, n := range authorNames {
lastFirsts[i] = toLastFirst(n)
}
authorNameLF := strings.Join(lastFirsts, ", ")
publishedYear := ""
if item.Year > 0 {
publishedYear = strconv.Itoa(item.Year)
}
genres := item.Genres
if genres == nil {
genres = []string{}
}
series := make([]map[string]any, 0, len(item.AudiobookSeries))
seriesNames := make([]string, 0, len(item.AudiobookSeries))
for _, membership := range item.AudiobookSeries {
name := strings.TrimSpace(membership.Name)
if name == "" {
continue
}
obj := map[string]any{"id": name, "name": name}
if membership.Index != nil {
obj["sequence"] = strconv.FormatFloat(*membership.Index, 'f', -1, 64)
}
series = append(series, obj)
seriesNames = append(seriesNames, name)
}
var publisher any
if len(item.Studios) > 0 && strings.TrimSpace(item.Studios[0]) != "" {
publisher = strings.TrimSpace(item.Studios[0])
}
return map[string]any{
"title": title,
"titleIgnorePrefix": titleIgnorePrefix(title),
"subtitle": nil,
"authors": authors,
"authorName": authorName,
"authorNameLF": authorNameLF,
"narrators": narrators,
"narratorName": strings.Join(narrators, ", "),
"series": series,
"seriesName": strings.Join(seriesNames, ", "),
"genres": genres,
"tags": []string{},
"publishedYear": publishedYear,
"publishedDate": nil,
"publisher": publisher,
"description": nilIfEmpty(item.Overview),
"descriptionPlain": nilIfEmpty(stripHTML(item.Overview)),
"isbn": nil,
"asin": nil,
"language": "en",
"explicit": false,
"abridged": false,
}
}
// buildSiloPlayLibraryItem builds the playbackSession.libraryItem nested
// object. The mobile player reads libraryItem.media.tracks /
// libraryItem.media.metadata / libraryItem.libraryFiles for offline download
// decisions and UI rendering.
func buildSiloPlayLibraryItem(
item *models.MediaItem,
contentID string,
mediaMetadata map[string]any,
audioTracks []AudioTrack,
chapters []map[string]any,
totalDuration float64,
baseURL string,
) map[string]any {
firstIno := contentID
if len(audioTracks) > 0 {
firstIno = audioTracks[0].Ino
}
totalSize := int64(0)
libraryFiles := make([]map[string]any, 0, len(audioTracks))
for _, t := range audioTracks {
nowMs := time.Now().UnixMilli()
libraryFiles = append(libraryFiles, map[string]any{
"ino": t.Ino,
"metadata": t.Metadata,
"isSupplementary": false,
"addedAt": nowMs,
"updatedAt": nowMs,
"fileType": "audio",
})
if t.Metadata != nil {
totalSize += t.Metadata.Size
}
}
addedAtMs := int64(0)
if item.AddedAt != nil {
addedAtMs = item.AddedAt.UnixMilli()
}
updatedAtMs := item.UpdatedAt.UnixMilli()
return map[string]any{
"id": contentID,
"ino": firstIno,
"oldLibraryItemId": nil,
"libraryId": VirtualLibraryID,
"folderId": VirtualFolderID,
"path": contentID,
"relPath": contentID,
"isFile": true,
"mtimeMs": nil,
"ctimeMs": nil,
"birthtimeMs": nil,
"addedAt": addedAtMs,
"updatedAt": updatedAtMs,
"lastScan": addedAtMs,
"scanVersion": ServerVersion,
"isMissing": false,
"isInvalid": false,
"mediaType": LibraryMediaType,
"media": map[string]any{
"id": contentID,
"libraryItemId": contentID,
"metadata": mediaMetadata,
"coverPath": baseURL + "/api/items/" + contentID + "/cover",
"tags": []any{},
"audioFiles": audioTracks,
"chapters": chapters,
"ebookFile": nil,
"duration": totalDuration,
"size": totalSize,
"tracks": audioTracks,
},
"libraryFiles": libraryFiles,
// size on the outer libraryItem is a STRING in real ABS wire format —
// mobile sort comparators string-compare it.
"size": strconv.FormatInt(totalSize, 10),
}
}
// ---------------------------------------------------------------------------
// Play response helpers
// ---------------------------------------------------------------------------
// nilIfEmpty returns nil when s is blank, otherwise the string itself.
// This mirrors the plugin's pattern: ABS clients treat null and ""
// differently on some fields (publisher, description, isbn, etc.).
func nilIfEmpty(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// titleIgnorePrefix strips leading articles for sort-key purposes,
// matching real ABS LibraryItemController behaviour.
func titleIgnorePrefix(title string) string {
lower := strings.ToLower(title)
for _, p := range []string{"the ", "a ", "an "} {
if strings.HasPrefix(lower, p) {
return title[len(p):]
}
}
return title
}
// toLastFirst converts "First Last" → "Last, First" for the authorNameLF
// field the mobile "Now Playing" widget renders.
func toLastFirst(name string) string {
parts := strings.Fields(name)
if len(parts) < 2 {
return name
}
return parts[len(parts)-1] + ", " + strings.Join(parts[:len(parts)-1], " ")
}
// stripHTML removes HTML angle-bracket tags from a description so the mobile
// "Now Playing" body renderer doesn't have to. Cheap and good enough for
// the descriptions silo metadata sources produce.
func stripHTML(s string) string {
if s == "" {
return ""
}
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
if !unicode.IsControl(r) {
b.WriteRune(r)
}
}
}
return strings.TrimSpace(b.String())
}
// resolveResumeTime returns the persisted currentTime for (userID, profileID,
// contentID) from the progress store, or 0 when no row exists / store is nil.
// Returned error is propagated so callers can log it; the caller is expected
// to fall back to 0 on error (a fresh-listen start is always correct).
func resolveResumeTime(ctx context.Context, store ProgressStore, userID, profileID, contentID string) (float64, error) {
if store == nil {
return 0, nil
}
row, err := store.GetProgress(ctx, userID, profileID, contentID)
if err != nil {
return 0, err
}
if row == nil {
return 0, nil
}
return row.CurrentSeconds, nil
}
@@ -0,0 +1,90 @@
package abs
import (
"context"
"errors"
"testing"
"time"
)
// fakeProgressStore is a minimal in-memory ProgressStore for the play
// resume tests. It returns a fixed row on GetProgress; other methods are
// no-ops sufficient to satisfy the interface.
type fakeProgressStore struct {
row *ProgressRow
getErr error
called bool
}
func (f *fakeProgressStore) GetProgress(_ context.Context, _, _, _ string) (*ProgressRow, error) {
f.called = true
return f.row, f.getErr
}
func (f *fakeProgressStore) ListProgressForAudiobooks(_ context.Context, _, _ string, _ int) ([]ProgressRow, error) {
return nil, nil
}
func (f *fakeProgressStore) UpsertProgress(_ context.Context, _ ProgressRow) error { return nil }
func (f *fakeProgressStore) UpdateProgressPosition(_ context.Context, _, _, _ string, _ float64) error {
return nil
}
func (f *fakeProgressStore) SetHideFromContinue(_ context.Context, _, _, _ string, _ bool) error {
return nil
}
func (f *fakeProgressStore) DeleteProgress(_ context.Context, _, _, _ string) error {
return nil
}
func TestResumeTimeFromProgressStore_HasRow(t *testing.T) {
store := &fakeProgressStore{
row: &ProgressRow{
UserID: "1",
ProfileID: "p1",
ContentID: "book123",
CurrentSeconds: 1234.5,
DurationSeconds: 5000,
UpdatedAt: time.Now(),
},
}
got, err := resolveResumeTime(context.Background(), store, "1", "p1", "book123")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != 1234.5 {
t.Errorf("resume time = %v, want 1234.5", got)
}
if !store.called {
t.Errorf("ProgressStore.GetProgress not called")
}
}
func TestResumeTimeFromProgressStore_NoRow(t *testing.T) {
store := &fakeProgressStore{row: nil}
got, err := resolveResumeTime(context.Background(), store, "1", "", "book123")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != 0 {
t.Errorf("resume time = %v, want 0", got)
}
}
func TestResumeTimeFromProgressStore_NilStore(t *testing.T) {
got, err := resolveResumeTime(context.Background(), nil, "1", "", "book123")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != 0 {
t.Errorf("resume time = %v, want 0", got)
}
}
func TestResumeTimeFromProgressStore_LookupError(t *testing.T) {
store := &fakeProgressStore{getErr: errors.New("boom")}
got, err := resolveResumeTime(context.Background(), store, "1", "", "book123")
if err == nil {
t.Errorf("expected error, got nil")
}
if got != 0 {
t.Errorf("resume time = %v, want 0 on error", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
package abs
import (
"context"
"time"
)
// PlaylistStore is the narrow slice of user_personal_collections
// (collection_type='playlist') and user_personal_collection_items the
// playlists handlers need. Implemented by ABSPlaylistStore in
// internal/audiobooks/abs_playlist_store.go; post-migration-156 it reads
// the unified canonical tables.
type PlaylistStore interface {
ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error)
GetPlaylist(ctx context.Context, id string) (Playlist, error)
CreatePlaylist(ctx context.Context, p Playlist) error
UpdatePlaylist(ctx context.Context, p Playlist) error
DeletePlaylist(ctx context.Context, id string) error
ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error)
AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
}
// Playlist is the in-memory representation of a user_personal_collections
// row with collection_type='playlist'.
type Playlist struct {
ID string
UserID string
ProfileID string
Name string
Description string
CoverItem string // empty when unset
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
// PlaylistItem is the in-memory representation of a
// user_personal_collection_items row scoped to a playlist (sub_item_id
// may be non-empty for podcast-episode entries).
type PlaylistItem struct {
PlaylistID string
LibraryItemID string
EpisodeID string // empty for audiobook items
Position int
AddedAt time.Time
}
// playlistToABS shapes a Playlist in the ABS wire format. When items
// is nil the list-shape is emitted (no "items" key); when items is
// non-nil (possibly empty) the full-shape is emitted.
//
// coverPath is omitted when CoverItem is empty (matches continuum).
// Description is always present (round-tripped from storage).
func playlistToABS(p Playlist, items []map[string]any) map[string]any {
out := map[string]any{
"id": p.ID,
"userId": p.UserID,
"name": p.Name,
"description": p.Description,
"isPublic": p.IsPublic,
"createdAt": p.CreatedAt.UnixMilli(),
"lastUpdate": p.UpdatedAt.UnixMilli(),
}
if p.CoverItem != "" {
out["coverPath"] = p.CoverItem
}
if items != nil {
out["items"] = items
}
return out
}
@@ -0,0 +1,61 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"time"
)
// TestPlaylistEnvelope_HasRequiredKeys asserts the eight (or nine with
// coverPath) top-level keys are present when populated. coverPath is
// emitted only when non-empty.
func TestPlaylistEnvelope_HasRequiredKeys(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
out := playlistToABS(Playlist{
ID: "01HPL",
UserID: "1",
Name: "queue",
Description: "",
CoverItem: "01HCOVER",
IsPublic: false,
CreatedAt: now,
UpdatedAt: now,
}, []map[string]any{})
body, _ := json.Marshal(out)
js := string(body)
for _, key := range []string{
`"id":`, `"userId":`, `"name":`, `"description":`,
`"isPublic":`, `"coverPath":`, `"createdAt":`, `"lastUpdate":`, `"items":`,
} {
if !strings.Contains(js, key) {
t.Errorf("envelope missing %s; got %s", key, js)
}
}
if out["coverPath"] != "01HCOVER" {
t.Errorf("coverPath = %v, want 01HCOVER", out["coverPath"])
}
}
// TestPlaylistEnvelope_OmitsCoverPathWhenEmpty asserts cover_item=""
// produces no coverPath key (matches continuum).
func TestPlaylistEnvelope_OmitsCoverPathWhenEmpty(t *testing.T) {
out := playlistToABS(Playlist{
ID: "01HPL", UserID: "1", Name: "x",
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}, []map[string]any{})
if _, has := out["coverPath"]; has {
t.Errorf("coverPath emitted when empty: %v", out)
}
}
// TestPlaylistListShape_OmitsItems asserts nil items produces no items key.
func TestPlaylistListShape_OmitsItems(t *testing.T) {
out := playlistToABS(Playlist{
ID: "01HPL", UserID: "1", Name: "x",
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}, nil)
if _, has := out["items"]; has {
t.Errorf("list-shape includes items key (should be detail-only): %v", out)
}
}
@@ -0,0 +1,635 @@
package abs
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
)
// playlistBody is the JSON body for POST and PATCH /playlists[/{id}].
// Fields are pointers so PATCH can distinguish "field absent" from
// "field set to empty/false". The real-ABS mobile client also sends
// `items` and `libraryId` on POST — the create flow expects the
// playlist + its initial members in a single round-trip — so we
// accept (and apply) both here. PATCH ignores them.
type playlistBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
CoverItem *string `json:"cover_item"`
IsPublic *bool `json:"isPublic"`
LibraryID *string `json:"libraryId"`
Items []playlistItemRef `json:"items"`
}
// playlistItemRef is the JSON body for adding/removing a single
// playlist item (and an element of the batch arrays).
type playlistItemRef struct {
LibraryItemID string `json:"libraryItemId"`
EpisodeID string `json:"episodeId"`
}
// handleCreatePlaylist — POST /playlists.
// Body: {name, description?, cover_item?, isPublic?}.
// Returns the created playlist in full-shape (empty items[]).
// Fires playlist_added on success.
func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist store unavailable", http.StatusServiceUnavailable)
return
}
var body playlistBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name == nil || *body.Name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
p := Playlist{
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
Name: *body.Name,
}
if body.Description != nil {
p.Description = *body.Description
}
if body.CoverItem != nil {
p.CoverItem = *body.CoverItem
}
if body.IsPublic != nil {
p.IsPublic = *body.IsPublic
}
if err := h.deps.PlaylistStore.CreatePlaylist(r.Context(), p); err != nil {
slog.Error("abs playlist create failed", "err", err, "user", a.UserID)
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
// Add any items the client sent on the same POST — real-ABS mobile
// builds the playlist + initial member list in one round-trip
// (see audiobookshelf-app components/modals/playlists/
// AddCreateModal.vue submitCreatePlaylist). Per-item errors are
// tolerated silently; whole-batch failure already surfaced above.
for _, it := range body.Items {
if it.LibraryItemID == "" {
continue
}
// Episode items skip audiobook MediaStore validation per the
// audiobook-only-hydration policy. Audiobook items get a
// MediaStore lookup so typos don't create orphan rows.
if it.EpisodeID == "" {
if mi, mErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access); mErr != nil || mi == nil {
slog.Debug("abs playlist create-items: skipping unknown audiobook", "id", it.LibraryItemID)
continue
}
}
if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), p.ID, it.LibraryItemID, it.EpisodeID); addErr != nil {
slog.Debug("abs playlist create-items: store error", "err", addErr, "id", it.LibraryItemID)
}
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), p.ID)
if errors.Is(err, ErrNotFound) {
persisted = p
} else if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_added", map[string]any{"id": p.ID, "name": p.Name})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// playlistFullShape renders a Playlist in full-shape, hydrating items[]
// via MediaStore for audiobook items (episode items echo bare refs).
func (h *Handler) playlistFullShape(r *http.Request, p Playlist) map[string]any {
items := h.playlistItems(r, p.ID)
return playlistToABS(p, items)
}
// playlistItems resolves items in a playlist to wire-shape entries.
// Each entry embeds a `libraryItem` block with the full LibraryItem
// shape — the ABS mobile client reads `item.libraryItem.mediaType`
// in PlaylistCover, LazyBookCard, and pages/playlist/_id.vue before
// rendering anything, so emitting bare {libraryItemId, title} causes
// "cannot read properties of undefined (reading mediaType)" on every
// playlist view. Episode items keep the bare ref shape; the official
// client treats episode rows separately via `item.episode`.
func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string]any {
if h.deps.PlaylistStore == nil {
return []map[string]any{}
}
rows, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID)
if err != nil {
slog.Warn("abs playlist list-items failed", "err", err, "playlist", playlistID)
return []map[string]any{}
}
access, _, _ := h.accessFilterFromRequest(r)
lib := h.resolveDefaultLibrary(r.Context(), access)
libID := audiobookLibraryID(lib)
baseURL := h.absBaseURL(r)
out := make([]map[string]any, 0, len(rows))
for _, it := range rows {
entry := map[string]any{
"libraryItemId": it.LibraryItemID,
"position": it.Position,
}
if it.EpisodeID != "" {
entry["episodeId"] = it.EpisodeID
} else if item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access); err == nil && item != nil {
entry["libraryId"] = libID
entry["title"] = item.Title
entry["libraryItem"] = siloItemToLibraryItem(item, lib, baseURL)
}
out = append(out, entry)
}
return out
}
// playlistURLID is a tiny shim around chi.URLParam(r, "id") to read
// uniformly with the collections handler's chiURLID.
func playlistURLID(r *http.Request) string { return chi.URLParam(r, "id") }
// handleListLibraryPlaylists — GET /libraries/{libraryId}/playlists.
//
// The ABS mobile create-playlist modal hits this endpoint BEFORE
// opening the form so it can show "already in playlist X" badges and
// the existing-playlists picker. It accesses `data.results` on the
// response (NOT `data.playlists`), and iterates `playlist.items` to
// check membership, so we emit:
//
// {"results": [Playlist full-shape with items[]]}
//
// The libraryId URL param is accepted but ignored — silo scopes
// playlists per (user, profile) globally rather than per-library;
// the mobile UI only needs to know which of the user's playlists
// already contain the selected item.
//
// Ref: audiobookshelf-app components/modals/playlists/AddCreateModal.vue
// loadPlaylists().
func (h *Handler) handleListLibraryPlaylists(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}, "total": 0})
return
}
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs library playlist list failed", "err", err, "user", a.UserID)
http.Error(w, "playlist list failed", http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(rows))
for _, p := range rows {
// Full-shape (with items[]) so the modal can check
// existing-membership via playlist.items.some(...).
out = append(out, h.playlistFullShape(r, p))
}
// LazyBookshelf reads payload.total to compute pagination; emit both
// for the bookshelf grid AND the create modal (modal ignores total).
writeJSON(w, http.StatusOK, map[string]any{"results": out, "total": len(out)})
}
// handleListPlaylists — GET /playlists.
// Returns the caller's playlists wrapped in {"playlists": [...]}.
// List-shape (no items[]).
func (h *Handler) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"playlists": []any{}})
return
}
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs playlist list failed", "err", err, "user", a.UserID)
http.Error(w, "playlist list failed", http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(rows))
for _, p := range rows {
out = append(out, playlistToABS(p, nil))
}
writeJSON(w, http.StatusOK, map[string]any{"playlists": out})
}
// handleGetPlaylist — GET /playlists/{id}.
// Owner gets full-shape; non-owner gets full-shape only when isPublic.
// Otherwise 404 (no existence leak).
func (h *Handler) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), playlistURLID(r))
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID) && !p.IsPublic) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get failed", "err", err)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, h.playlistFullShape(r, p))
}
// handleUpdatePlaylist — PATCH /playlists/{id}.
// Owner-only. Partial body. Fires playlist_updated.
func (h *Handler) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-update failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
var body playlistBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name != nil {
p.Name = *body.Name
}
if body.Description != nil {
p.Description = *body.Description
}
if body.CoverItem != nil {
p.CoverItem = *body.CoverItem
}
if body.IsPublic != nil {
p.IsPublic = *body.IsPublic
}
if err := h.deps.PlaylistStore.UpdatePlaylist(r.Context(), p); err != nil {
slog.Error("abs playlist update failed", "err", err, "id", id)
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_updated", map[string]any{"id": id})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// handleAddPlaylistItem — POST /playlists/{id}/item.
// Body: {libraryItemId, episodeId?}.
// Owner-only. Item validation: audiobooks validated via MediaStore
// (404 on unknown); episode items skip validation per spec §7.1 (the
// audiobook-only-hydration policy doesn't reject opaque episode IDs).
// Idempotent on (libraryItemId, episodeId) tuple. Fires playlist_updated.
func (h *Handler) handleAddPlaylistItem(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-add failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
var body playlistItemRef
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.LibraryItemID == "" {
http.Error(w, "libraryItemId required", http.StatusBadRequest)
return
}
// Audiobook items validated; episodes skip validation.
if body.EpisodeID == "" {
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), body.LibraryItemID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
}
if err := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, body.LibraryItemID, body.EpisodeID); err != nil {
slog.Error("abs playlist add-item failed", "err", err, "id", id)
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_updated", map[string]any{"id": id})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// handleDeletePlaylist — DELETE /playlists/{id}.
// Owner-only. Cascade drops user_personal_collection_items via FK.
// Fires playlist_removed.
func (h *Handler) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-delete failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), id); err != nil {
slog.Error("abs playlist delete failed", "err", err, "id", id)
http.Error(w, "playlist delete failed", http.StatusInternalServerError)
return
}
h.publish(a.UserID, "playlist_removed", map[string]any{"id": id})
w.WriteHeader(http.StatusNoContent)
}
// batchItemsBody is the shared body shape for batch add/remove.
type batchItemsBody struct {
Items []playlistItemRef `json:"items"`
}
// handleBatchAddPlaylistItems — POST /playlists/{id}/batch/add.
// Body: {items: [{libraryItemId, episodeId?}]}. Per-item failures are
// tolerated silently (matches continuum). Only the whole-body decode
// failure surfaces as 400. Audiobook items validated per-entry; failed
// validations skipped with slog.Debug (the entry never reaches the
// store). One playlist_updated event fires for the whole batch.
func (h *Handler) handleBatchAddPlaylistItems(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-batch-add failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
var body batchItemsBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
for _, it := range body.Items {
if it.LibraryItemID == "" {
slog.Debug("abs playlist batch-add: skipping empty libraryItemId")
continue
}
// Audiobook validation; episode items skip.
if it.EpisodeID == "" {
access, accessErr := h.accessFilterForAuth(r.Context(), a)
if accessErr != nil {
slog.Debug("abs playlist batch-add: skipping access-denied audiobook", "id", it.LibraryItemID, "err", accessErr)
continue
}
item, lookupErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access)
if lookupErr != nil || item == nil {
slog.Debug("abs playlist batch-add: skipping unknown audiobook", "id", it.LibraryItemID)
continue
}
}
if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); addErr != nil {
slog.Debug("abs playlist batch-add: store error", "err", addErr, "id", it.LibraryItemID)
}
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_updated", map[string]any{"id": id})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// handleBatchRemovePlaylistItems — POST /playlists/{id}/batch/remove.
// Body: {items: [{libraryItemId, episodeId?}]}. Per-item failures
// tolerated; one playlist_updated event for the whole batch.
func (h *Handler) handleBatchRemovePlaylistItems(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-batch-remove failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
var body batchItemsBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
for _, it := range body.Items {
if rmErr := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); rmErr != nil {
slog.Debug("abs playlist batch-remove: store error", "err", rmErr, "id", it.LibraryItemID)
}
}
if h.autoDeleteIfEmpty(w, r, a.UserID, id, p) {
return
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_updated", map[string]any{"id": id})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// handleRemovePlaylistItem — DELETE /playlists/{id}/item/{libraryItemId}.
// Owner-only. Removes the item with empty episode_id. Idempotent.
// Fires playlist_updated.
func (h *Handler) handleRemovePlaylistItem(w http.ResponseWriter, r *http.Request) {
h.removePlaylistItemImpl(w, r, "")
}
// handleRemovePlaylistEpisode — DELETE /playlists/{id}/item/{libraryItemId}/{episodeId}.
// Owner-only. Removes the item keyed on (libraryItemId, episodeId).
// Idempotent. Fires playlist_updated.
func (h *Handler) handleRemovePlaylistEpisode(w http.ResponseWriter, r *http.Request) {
h.removePlaylistItemImpl(w, r, chi.URLParam(r, "episodeId"))
}
// removePlaylistItemImpl is the shared body for both remove variants.
// episodeIDFromURL is "" for the libraryItemId-only DELETE and the
// {episodeId} URL param for the episode-aware DELETE.
func (h *Handler) removePlaylistItemImpl(w http.ResponseWriter, r *http.Request, episodeIDFromURL string) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
id := playlistURLID(r)
libItem := chi.URLParam(r, "libraryItemId")
if libItem == "" {
http.Error(w, "libraryItemId required", http.StatusBadRequest)
return
}
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, p.UserID, p.ProfileID)) {
http.Error(w, "playlist not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs playlist get-for-remove failed", "err", err, "id", id)
http.Error(w, "playlist get failed", http.StatusInternalServerError)
return
}
if err := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, libItem, episodeIDFromURL); err != nil {
slog.Error("abs playlist remove-item failed", "err", err, "id", id, "item", libItem, "episode", episodeIDFromURL)
http.Error(w, "playlist delete failed", http.StatusInternalServerError)
return
}
if h.autoDeleteIfEmpty(w, r, a.UserID, id, p) {
return
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), id)
if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_updated", map[string]any{"id": id})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// autoDeleteIfEmpty mirrors the official audiobookshelf-server behavior
// where a playlist is destroyed once its final item is removed. After a
// successful item-remove the caller invokes this; if the playlist is now
// empty it is deleted and a `playlist_removed` event is fired (mobile
// client's pages/playlist/_id.vue uses this to navigate the user back to
// /bookshelf/playlists). Returns true when the handler has fully written
// the response and the caller should stop.
//
// Errors during the empty-check or delete step do not block the original
// remove from succeeding — we log and fall back to the standard
// playlist_updated path so the client at minimum sees an empty playlist
// (consistent with our pre-auto-delete behavior).
func (h *Handler) autoDeleteIfEmpty(w http.ResponseWriter, r *http.Request, userID, playlistID string, original Playlist) bool {
items, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID)
if err != nil {
slog.Warn("abs playlist auto-delete: count failed", "err", err, "id", playlistID)
return false
}
if len(items) > 0 {
return false
}
if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), playlistID); err != nil {
slog.Warn("abs playlist auto-delete: delete failed", "err", err, "id", playlistID)
return false
}
h.publish(userID, "playlist_removed", map[string]any{"id": playlistID})
// Echo the now-deleted playlist as the response body — the official
// server returns the playlist (with empty items[]) so the client
// reconciles state regardless of whether the socket event arrives
// first.
writeJSON(w, http.StatusOK, playlistToABS(original, []map[string]any{}))
return true
}
@@ -0,0 +1,713 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"sort"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// memPlaylistStore is an in-memory PlaylistStore for handler tests.
type memPlaylistStore struct {
mu sync.Mutex
rows map[string]Playlist // id -> row
items map[string][]PlaylistItem // playlist_id -> items
}
func newMemPlaylistStore() *memPlaylistStore {
return &memPlaylistStore{
rows: map[string]Playlist{},
items: map[string][]PlaylistItem{},
}
}
func (m *memPlaylistStore) ListUserPlaylists(_ context.Context, userID, profileID string) ([]Playlist, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Playlist, 0)
for _, p := range m.rows {
if p.UserID == userID && p.ProfileID == profileID {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memPlaylistStore) GetPlaylist(_ context.Context, id string) (Playlist, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.rows[id]
if !ok {
return Playlist{}, ErrNotFound
}
return p, nil
}
func (m *memPlaylistStore) CreatePlaylist(_ context.Context, p Playlist) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[p.ID] = p
return nil
}
func (m *memPlaylistStore) UpdatePlaylist(_ context.Context, p Playlist) error {
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.rows[p.ID]
if !ok {
return ErrNotFound
}
existing.Name = p.Name
existing.Description = p.Description
existing.CoverItem = p.CoverItem
existing.IsPublic = p.IsPublic
existing.UpdatedAt = time.Now()
m.rows[p.ID] = existing
return nil
}
func (m *memPlaylistStore) DeletePlaylist(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, id)
delete(m.items, id)
return nil
}
func (m *memPlaylistStore) ListPlaylistItems(_ context.Context, playlistID string) ([]PlaylistItem, error) {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[playlistID]
out := make([]PlaylistItem, len(items))
copy(out, items)
sort.Slice(out, func(i, j int) bool { return out[i].Position < out[j].Position })
return out, nil
}
func (m *memPlaylistStore) AddPlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, it := range m.items[playlistID] {
if it.LibraryItemID == libraryItemID && it.EpisodeID == episodeID {
return nil // ON CONFLICT DO NOTHING
}
}
maxPos := 0
for _, it := range m.items[playlistID] {
if it.Position > maxPos {
maxPos = it.Position
}
}
m.items[playlistID] = append(m.items[playlistID], PlaylistItem{
PlaylistID: playlistID,
LibraryItemID: libraryItemID,
EpisodeID: episodeID,
Position: maxPos + 1,
AddedAt: time.Now(),
})
if p, ok := m.rows[playlistID]; ok {
p.UpdatedAt = time.Now()
m.rows[playlistID] = p
}
return nil
}
func (m *memPlaylistStore) RemovePlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[playlistID]
out := items[:0]
for _, it := range items {
if it.LibraryItemID != libraryItemID || it.EpisodeID != episodeID {
out = append(out, it)
}
}
m.items[playlistID] = out
if p, ok := m.rows[playlistID]; ok {
p.UpdatedAt = time.Now()
m.rows[playlistID] = p
}
return nil
}
type playlistsHarness struct {
H *Handler
Play *memPlaylistStore
Pub *recordingPublisher
}
func newPlaylistsHarness(t *testing.T, knownItems ...string) *playlistsHarness {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
pub := &recordingPublisher{}
store := newMemPlaylistStore()
h := New(Dependencies{
MediaStore: &stubMediaStore{known: known},
PlaylistStore: store,
Publisher: pub,
})
return &playlistsHarness{H: h, Play: store, Pub: pub}
}
func TestPlaylist_Create_ReturnsFullShape(t *testing.T) {
hb := newPlaylistsHarness(t)
body := []byte(`{"name":"queue","description":"d","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, body, "1", "", hb.H.handleCreatePlaylist)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "queue" {
t.Errorf("name = %v, want queue", got["name"])
}
if got["isPublic"] != true {
t.Errorf("isPublic = %v, want true", got["isPublic"])
}
items, _ := got["items"].([]any)
if items == nil {
t.Errorf("items missing on full-shape: %v", got)
}
}
func TestPlaylist_Create_NameRequired_400(t *testing.T) {
hb := newPlaylistsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{}`), "1", "", hb.H.handleCreatePlaylist)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestPlaylist_Create_FiresPlaylistAddedEvent(t *testing.T) {
hb := newPlaylistsHarness(t)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{"name":"queue"}`), "7", "", hb.H.handleCreatePlaylist)
evts := hb.Pub.snapshot()
if len(evts) != 1 {
t.Fatalf("events = %d, want 1", len(evts))
}
if evts[0].Event != "playlist_added" {
t.Errorf("event = %q, want playlist_added", evts[0].Event)
}
if evts[0].UserID != "7" {
t.Errorf("event userID = %q, want 7", evts[0].UserID)
}
payload, _ := evts[0].Payload.(map[string]any)
if payload["name"] != "queue" {
t.Errorf("payload name = %v, want queue", payload["name"])
}
}
func createPlaylistForUser(t *testing.T, hb *playlistsHarness, userID, profileID, body string) string {
t.Helper()
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(body), userID, profileID, hb.H.handleCreatePlaylist)
if rec.Code != http.StatusOK {
t.Fatalf("seed POST status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
id, _ := got["id"].(string)
if id == "" {
t.Fatalf("seed POST returned no id; body=%s", rec.Body.String())
}
return id
}
func TestPlaylist_List_WrappedEnvelope(t *testing.T) {
hb := newPlaylistsHarness(t)
_ = createPlaylistForUser(t, hb, "1", "", `{"name":"a"}`)
_ = createPlaylistForUser(t, hb, "1", "", `{"name":"b"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists", nil, nil, "1", "", hb.H.handleListPlaylists)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, ok := env["playlists"].([]any)
if !ok {
t.Fatalf("response missing 'playlists' key; body=%s", rec.Body.String())
}
if len(list) != 2 {
t.Errorf("list len = %d, want 2", len(list))
}
for _, p := range list {
entry := p.(map[string]any)
if _, has := entry["items"]; has {
t.Errorf("list entry has items key (should be detail-only): %v", entry)
}
}
}
func TestPlaylist_List_ProfileIsolation(t *testing.T) {
hb := newPlaylistsHarness(t)
pA := "00000000-0000-0000-0000-0000000000aa"
pB := "00000000-0000-0000-0000-0000000000bb"
_ = createPlaylistForUser(t, hb, "1", pA, `{"name":"A"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists", nil, nil, "1", pB, hb.H.handleListPlaylists)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, _ := env["playlists"].([]any)
if len(list) != 0 {
t.Errorf("profile B sees %d playlists, want 0", len(list))
}
}
func TestPlaylist_Get_Owner_ReturnsFullShape(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetPlaylist)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "mine" {
t.Errorf("name = %v, want 'mine'", got["name"])
}
if _, has := got["items"]; !has {
t.Errorf("items missing on full-shape: %v", got)
}
}
func TestPlaylist_Get_NonOwner_Private_404(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"private"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetPlaylist)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestPlaylist_Get_NonOwner_Public_OK(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetPlaylist)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestPlaylist_Get_Unknown_404(t *testing.T) {
hb := newPlaylistsHarness(t)
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/01HZZZ", map[string]string{"id": "01HZZZ"}, nil, "1", "", hb.H.handleGetPlaylist)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPlaylist_Patch_UpdatesCover(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"x"}`)
body := []byte(`{"cover_item":"01HCOVER"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, body, "1", "", hb.H.handleUpdatePlaylist)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["coverPath"] != "01HCOVER" {
t.Errorf("coverPath = %v, want 01HCOVER", got["coverPath"])
}
}
func TestPlaylist_Patch_FiresUpdatedEvent(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "7", "", `{"name":"x"}`)
// snapshot count after create
before := len(hb.Pub.snapshot())
_ = dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, []byte(`{"name":"renamed"}`), "7", "", hb.H.handleUpdatePlaylist)
evts := hb.Pub.snapshot()
if len(evts) != before+1 {
t.Fatalf("events = %d (delta %d), want exactly 1 new event", len(evts), len(evts)-before)
}
if evts[len(evts)-1].Event != "playlist_updated" {
t.Errorf("event = %q, want playlist_updated", evts[len(evts)-1].Event)
}
}
func TestPlaylist_Patch_NonOwner_404(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, []byte(`{"name":"hijack"}`), "2", "", hb.H.handleUpdatePlaylist)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
p, _ := hb.Play.GetPlaylist(context.Background(), id)
if p.Name != "mine" {
t.Errorf("non-owner mutation leaked: name = %q", p.Name)
}
}
func TestPlaylist_Delete_Owner_FiresRemovedEvent(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "7", "", `{"name":"x"}`)
before := len(hb.Pub.snapshot())
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id, map[string]string{"id": id}, nil, "7", "", hb.H.handleDeletePlaylist)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
evts := hb.Pub.snapshot()
if len(evts) != before+1 {
t.Fatalf("events = %d, want exactly 1 new event", len(evts)-before)
}
if evts[len(evts)-1].Event != "playlist_removed" {
t.Errorf("event = %q, want playlist_removed", evts[len(evts)-1].Event)
}
// Post-delete GET must 404 (symmetry with collection delete test).
rec2 := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "7", "", hb.H.handleGetPlaylist)
if rec2.Code != http.StatusNotFound {
t.Errorf("post-delete GET status = %d, want 404", rec2.Code)
}
}
func TestPlaylist_Delete_NonOwner_404(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleDeletePlaylist)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if _, err := hb.Play.GetPlaylist(context.Background(), id); err != nil {
t.Errorf("playlist wrongly deleted: %v", err)
}
}
func TestPlaylist_AddItem_AudiobookHydrates(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"libraryItemId":"book-1"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
entry := items[0].(map[string]any)
if entry["libraryItemId"] != "book-1" {
t.Errorf("libraryItemId = %v, want book-1", entry["libraryItemId"])
}
if _, has := entry["title"]; !has {
t.Errorf("audiobook item missing 'title' hydration: %v", entry)
}
if pos, _ := entry["position"].(float64); pos != 1 {
t.Errorf("first item position = %v, want 1", entry["position"])
}
}
func TestPlaylist_AddItem_AppendsAtNextPosition(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1", "book-2", "book-3")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
for _, b := range []string{"book-1", "book-2", "book-3"} {
body := []byte(`{"libraryItemId":"` + b + `"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
}
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetPlaylist)
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 3 {
t.Fatalf("items len = %d, want 3", len(items))
}
for i, raw := range items {
entry := raw.(map[string]any)
wantPos := float64(i + 1)
if entry["position"] != wantPos {
t.Errorf("items[%d] position = %v, want %v", i, entry["position"], wantPos)
}
}
}
func TestPlaylist_AddItem_Idempotent(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"libraryItemId":"book-1"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item", map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item", map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusOK {
t.Fatalf("second add status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Errorf("items len = %d, want 1 (idempotent)", len(items))
}
}
func TestPlaylist_AddItem_Episode_AcceptsAndEchoes(t *testing.T) {
hb := newPlaylistsHarness(t /* no known items - episode skips validation */)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-1"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
entry := items[0].(map[string]any)
if entry["episodeId"] != "ep-1" {
t.Errorf("episodeId = %v, want ep-1", entry["episodeId"])
}
if _, has := entry["title"]; has {
t.Errorf("episode item must NOT be hydrated: %v", entry)
}
}
func TestPlaylist_AddItem_UnknownAudiobook_404(t *testing.T) {
hb := newPlaylistsHarness(t /* no known items */)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"libraryItemId":"ghost"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (item not found); body=%s", rec.Code, rec.Body.String())
}
}
func TestPlaylist_AddItem_LibraryItemIdRequired_400(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"libraryItemId":""}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestPlaylist_AddItem_NonOwner_404(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
body := []byte(`{"libraryItemId":"book-1"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "2", "", hb.H.handleAddPlaylistItem)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPlaylist_AddItem_FiresUpdatedEvent(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "7", "", `{"name":"q"}`)
before := len(hb.Pub.snapshot())
body := []byte(`{"libraryItemId":"book-1"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, body, "7", "", hb.H.handleAddPlaylistItem)
evts := hb.Pub.snapshot()
if len(evts) != before+1 {
t.Fatalf("events = %d, want exactly 1 new event", len(evts)-before)
}
if evts[len(evts)-1].Event != "playlist_updated" {
t.Errorf("event = %q, want playlist_updated", evts[len(evts)-1].Event)
}
}
func TestPlaylist_RemoveItem_Single(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1", "book-2")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-1"}`), "1", "", hb.H.handleAddPlaylistItem)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-2"}`), "1", "", hb.H.handleAddPlaylistItem)
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-1",
map[string]string{"id": id, "libraryItemId": "book-1"}, nil, "1", "", hb.H.handleRemovePlaylistItem)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
if items[0].(map[string]any)["libraryItemId"] != "book-2" {
t.Errorf("remaining item = %v, want book-2", items[0])
}
}
func TestPlaylist_RemoveItem_Idempotent(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-99",
map[string]string{"id": id, "libraryItemId": "book-99"}, nil, "1", "", hb.H.handleRemovePlaylistItem)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (idempotent)", rec.Code)
}
}
func TestPlaylist_RemoveItem_WithEpisode(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
// Seed two items at the same libraryItemId — one with episode, one without.
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-1"}`), "1", "", hb.H.handleAddPlaylistItem)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-2"}`), "1", "", hb.H.handleAddPlaylistItem)
// Remove ep-1 specifically.
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/podcast-x/ep-1",
map[string]string{"id": id, "libraryItemId": "podcast-x", "episodeId": "ep-1"}, nil, "1", "", hb.H.handleRemovePlaylistEpisode)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1 (ep-2 should remain)", len(items))
}
if items[0].(map[string]any)["episodeId"] != "ep-2" {
t.Errorf("remaining item episodeId = %v, want ep-2", items[0])
}
}
func TestPlaylist_RemoveItem_NonOwner_404(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-1"}`), "1", "", hb.H.handleAddPlaylistItem)
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-1",
map[string]string{"id": id, "libraryItemId": "book-1"}, nil, "2", "", hb.H.handleRemovePlaylistItem)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
items, _ := hb.Play.ListPlaylistItems(context.Background(), id)
if len(items) != 1 {
t.Errorf("items len = %d, want 1 (non-owner remove leaked)", len(items))
}
}
func TestPlaylist_BatchAdd_TolerantOfPartialFailures(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1", "book-2") // book-3 unknown
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-3"},{"libraryItemId":"book-2"}]}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchAddPlaylistItems)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 2 {
t.Errorf("items len = %d, want 2 (book-3 skipped)", len(items))
}
}
func TestPlaylist_BatchAdd_FiresOneUpdatedEvent(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1", "book-2")
id := createPlaylistForUser(t, hb, "7", "", `{"name":"q"}`)
before := len(hb.Pub.snapshot())
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-2"}]}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
map[string]string{"id": id}, body, "7", "", hb.H.handleBatchAddPlaylistItems)
evts := hb.Pub.snapshot()
if len(evts)-before != 1 {
t.Errorf("event delta = %d, want exactly 1", len(evts)-before)
}
}
func TestPlaylist_BatchAdd_EmptyItems_OKNoOp(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
body := []byte(`{"items":[]}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchAddPlaylistItems)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestPlaylist_BatchAdd_InvalidBody_400(t *testing.T) {
hb := newPlaylistsHarness(t)
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
map[string]string{"id": id}, []byte(`{not json`), "1", "", hb.H.handleBatchAddPlaylistItems)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestPlaylist_BatchAdd_NonOwner_404(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
body := []byte(`{"items":[{"libraryItemId":"book-1"}]}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
map[string]string{"id": id}, body, "2", "", hb.H.handleBatchAddPlaylistItems)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPlaylist_BatchRemove(t *testing.T) {
hb := newPlaylistsHarness(t, "book-1", "book-2", "book-3")
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
for _, b := range []string{"book-1", "book-2", "book-3"} {
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
map[string]string{"id": id}, []byte(`{"libraryItemId":"`+b+`"}`), "1", "", hb.H.handleAddPlaylistItem)
}
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-3"}]}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/remove",
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchRemovePlaylistItems)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
items, _ := got["items"].([]any)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1 (book-2 should remain)", len(items))
}
if items[0].(map[string]any)["libraryItemId"] != "book-2" {
t.Errorf("remaining item = %v, want book-2", items[0])
}
}
+442
View File
@@ -0,0 +1,442 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
// ProgressStore is the narrow slice of user_watch_progress access the ABS
// handlers need. Implemented by ABSProgressStore in
// internal/audiobooks/abs_progress_store.go.
type ProgressStore interface {
// GetProgress returns the progress row for (userID, profileID, contentID).
// Returns (nil, nil) when no row exists (not an error).
GetProgress(ctx context.Context, userID, profileID, contentID string) (*ProgressRow, error)
// ListProgressForAudiobooks returns all progress rows for (userID, profileID)
// that correspond to audiobooks (media_items.type = 'audiobook').
// Capped at limit rows (most-recently-updated first).
ListProgressForAudiobooks(ctx context.Context, userID, profileID string, limit int) ([]ProgressRow, error)
// UpsertProgress writes a progress row. Fields not set in the body
// (currentTime/duration/isFinished/progress) should be merged by the
// caller before invoking this.
UpsertProgress(ctx context.Context, row ProgressRow) error
// UpdateProgressPosition updates only the position_seconds field for
// (userID, profileID, contentID). Used by session sync to avoid overwriting
// is_finished / progress_pct that the user set explicitly.
UpdateProgressPosition(ctx context.Context, userID, profileID, contentID string, positionSeconds float64) error
// SetHideFromContinue toggles the hide_from_continue flag on a
// progress row. Idempotent — succeeds even when no row matches.
SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error
// DeleteProgress removes the progress row for (userID, profileID, contentID).
// Idempotent — succeeds even when no row matches. Used by the ABS
// "Reset Progress" affordance: DELETE /api/me/progress/{libraryItemId}.
DeleteProgress(ctx context.Context, userID, profileID, contentID string) error
}
// ABSPlaybackSessionStore tracks the active /abs/api/items/{id}/play sessions
// for per-session listening-time accounting (migration 143).
// Implemented by ABSPlaybackSessionStore in
// internal/audiobooks/abs_playback_session_store.go.
type ABSPlaybackSessionStore interface {
// InsertPlaybackSession creates the session row at play-start.
InsertPlaybackSession(ctx context.Context, sess ABSPlaybackSession) error
// GetPlaybackSession fetches a session by its ULID. Returns ErrNotFound
// when absent.
GetPlaybackSession(ctx context.Context, id string) (ABSPlaybackSession, error)
// SyncPlaybackSession updates position + accumulated listening time.
SyncPlaybackSession(ctx context.Context, id string, currentPositionSeconds float64, timeListeningSeconds int) error
// ClosePlaybackSession sets closed_at to now().
ClosePlaybackSession(ctx context.Context, id string) error
// CloseOpenSessionsForPrincipal closes all active sessions for a user
// profile, used when logout revokes that profile's tokens.
CloseOpenSessionsForPrincipal(ctx context.Context, userID, profileID string) error
// AggregateStats returns aggregated listening stats for (user, profile).
AggregateStats(ctx context.Context, userID, profileID string) (Stats, error)
// ListClosedSessions returns paginated closed sessions for (user, profile)
// ordered by started_at DESC. Returns (rows, totalRowCount, error).
ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error)
}
// Stats is the aggregated /me/listening-stats response shape.
type Stats struct {
TotalTime int // seconds
Items int // distinct content_ids listened to
Days []DayStat // recent days (most-recent first)
DayOfWeek [7]int // index 0 = Sunday
Monthly []MonthStat
}
type DayStat struct {
Date string
Seconds int
}
type MonthStat struct {
Month string
Seconds int
}
// ProgressRow is the in-memory representation of a user_watch_progress row
// as the ABS handlers use it. Intentionally narrow — only the fields the ABS
// wire format cares about.
type ProgressRow struct {
UserID string
ProfileID string
ContentID string
CurrentSeconds float64
DurationSeconds float64
ProgressPct float64
IsFinished bool
UpdatedAt time.Time
}
// ABSPlaybackSession is the in-memory representation of an abs_playback_sessions row.
type ABSPlaybackSession struct {
ID string
UserID string
ProfileID string
ContentID string
MediaFileID *int
TimeListeningSeconds int
CurrentPositionSeconds float64
StartedAt time.Time
LastSyncAt time.Time
ClosedAt *time.Time
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// handleGetMyProgress — GET /abs/api/me/progress
// Lists all progress rows for the caller that belong to audiobooks.
// The ABS mobile client reads this on startup to seed resume positions.
func (h *Handler) handleGetMyProgress(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.ProgressStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"mediaProgress": []any{}})
return
}
rows, err := h.deps.ProgressStore.ListProgressForAudiobooks(r.Context(), a.UserID, a.ProfileID, 500)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
out := make([]map[string]any, 0, len(rows))
for _, p := range rows {
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), p.ContentID, access)
if err != nil || item == nil {
continue
}
out = append(out, progressRowToABS(p))
}
writeJSON(w, http.StatusOK, map[string]any{"mediaProgress": out})
}
// handleGetItemProgress — GET /abs/api/me/progress/{libraryItemId}
// Returns the progress row for one item. 404 when no progress exists.
func (h *Handler) handleGetItemProgress(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "libraryItemId")
if h.deps.ProgressStore == nil {
http.Error(w, "progress not found", http.StatusNotFound)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID, access)
if err != nil || item == nil {
http.Error(w, "progress not found", http.StatusNotFound)
return
}
p, err := h.deps.ProgressStore.GetProgress(r.Context(), a.UserID, a.ProfileID, contentID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if p == nil {
http.Error(w, "progress not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, progressRowToABS(*p))
}
// progressBody is the JSON body for POST/PATCH /api/me/progress/{libraryItemId}.
// All fields are optional — only present fields update the row (PATCH semantics).
//
// EbookProgress / EbookLocation are emitted by the ABS clients (AudioBooth's
// BooksService writes them on every page turn). silo's audiobook-first catalog
// doesn't yet persist ebook position; the fields are accepted-and-ignored so
// the client write succeeds and the user isn't shown a sync error. They will
// flow into a dedicated ebook progress column when the ebook scanner lands.
type progressBody struct {
CurrentTime *float64 `json:"currentTime"`
Duration *float64 `json:"duration"`
IsFinished *bool `json:"isFinished"`
Progress *float64 `json:"progress"`
EbookProgress *float64 `json:"ebookProgress"`
EbookLocation *string `json:"ebookLocation"`
}
// handleSetItemProgress — POST /abs/api/me/progress/{libraryItemId}
// UPSERTs the progress row. Merges body fields over any existing row so a
// partial body (only currentTime) doesn't reset duration/isFinished.
//
// This matches sub-plan 3's HandleReportAudiobookProgress in intent but uses
// the ABS wire format and calls ProgressStore directly so the two code paths
// don't need a shared helper — their thresholds/semantics differ enough that
// keeping them separate is cleaner.
func (h *Handler) handleSetItemProgress(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
contentID := chi.URLParam(r, "libraryItemId")
var body progressBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if h.deps.ProgressStore == nil {
http.Error(w, "progress store unavailable", http.StatusServiceUnavailable)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
// Read existing row to merge (PATCH semantics).
var cur ProgressRow
if existing, err := h.deps.ProgressStore.GetProgress(r.Context(), a.UserID, a.ProfileID, contentID); err == nil && existing != nil {
cur = *existing
}
next := ProgressRow{
UserID: a.UserID,
ProfileID: a.ProfileID,
ContentID: contentID,
CurrentSeconds: cur.CurrentSeconds,
DurationSeconds: cur.DurationSeconds,
ProgressPct: cur.ProgressPct,
IsFinished: cur.IsFinished,
UpdatedAt: time.Now(),
}
if body.CurrentTime != nil {
next.CurrentSeconds = *body.CurrentTime
}
if body.Duration != nil {
next.DurationSeconds = *body.Duration
}
if body.Progress != nil {
next.ProgressPct = *body.Progress
}
if body.IsFinished != nil {
next.IsFinished = *body.IsFinished
}
if err := h.deps.ProgressStore.UpsertProgress(r.Context(), next); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
updated, err := h.deps.ProgressStore.GetProgress(r.Context(), a.UserID, a.ProfileID, contentID)
if err != nil || updated == nil {
// Best-effort: return the in-memory merged row rather than failing.
h.publish(a.UserID, "user_item_progress_updated", map[string]any{"data": progressRowToABS(next)})
writeJSON(w, http.StatusOK, progressRowToABS(next))
return
}
payload := progressRowToABS(*updated)
h.publish(a.UserID, "user_item_progress_updated", map[string]any{"data": payload})
writeJSON(w, http.StatusOK, payload)
}
// syncPayload is the JSON body for PATCH /abs/api/session/{sid}/sync.
type syncPayload struct {
CurrentTime float64 `json:"currentTime"`
// Accept both spellings: real ABS clients send "timeListening"; an older
// silo-plugin draft used "timeListened". UnmarshalJSON merges them.
TimeListening float64 `json:"timeListening"`
TimeListened float64 `json:"timeListened"`
}
// timeDelta returns the accumulated listening time from whichever spelling
// the client used. Both fields are tried; non-zero wins.
func (p syncPayload) timeDelta() float64 {
if p.TimeListening != 0 {
return p.TimeListening
}
return p.TimeListened
}
// handleSessionSync — PATCH /abs/api/session/{sid}/sync
// Heartbeat endpoint the ABS mobile client calls every ~10 s during playback.
// Updates current position in user_watch_progress and accumulates
// time_listening_seconds in abs_playback_sessions.
//
// IDOR guard: the session must belong to the calling user (404 otherwise
// so session existence isn't leaked to other users).
//
// Uses UpdateProgressPosition (not UpsertProgress) to avoid overwriting
// is_finished / progress_pct that the user set explicitly — a sync tick
// that arrives after the user marks a book finished must not un-finish it.
func (h *Handler) handleSessionSync(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sid := chi.URLParam(r, "sid")
var p syncPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if h.deps.PlaybackSessionStore == nil {
// No session store wired yet — accept the sync but return success
// rather than blocking the player.
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
// Ownership gate.
sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid)
if err != nil || !sameABSPrincipal(a, sess.UserID, sess.ProfileID) {
http.Error(w, "session not found", http.StatusNotFound)
return
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), sess.ContentID, access)
if err != nil || item == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
// Accumulate listening time and update position in abs_playback_sessions.
if err := h.deps.PlaybackSessionStore.SyncPlaybackSession(
r.Context(), sid, p.CurrentTime, int(p.timeDelta()),
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Update position in user_watch_progress. Must NOT be a full upsert —
// see comment in handleSetItemProgress re: not overwriting is_finished.
if h.deps.ProgressStore != nil {
_ = h.deps.ProgressStore.UpdateProgressPosition(
r.Context(), a.UserID, a.ProfileID, sess.ContentID, p.CurrentTime,
)
}
// Realtime push to other connected clients.
h.publish(a.UserID, "user_item_progress_updated", map[string]any{
"data": map[string]any{
"libraryItemId": sess.ContentID,
"currentTime": p.CurrentTime,
"sessionId": sid,
},
})
h.publish(a.UserID, "user_session_updated", map[string]any{
"id": sid,
"libraryItemId": sess.ContentID,
"currentTime": p.CurrentTime,
"timeListening": p.timeDelta(),
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleSessionClose — POST /abs/api/session/{sid}/close
// Finalises a play session. Sets closed_at on the abs_playback_sessions row.
// Only the owning user may close their session (IDOR guard).
func (h *Handler) handleSessionClose(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sid := chi.URLParam(r, "sid")
if h.deps.PlaybackSessionStore == nil {
// No store wired — accept close gracefully.
w.WriteHeader(http.StatusNoContent)
return
}
// Ownership gate.
sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid)
if err != nil || !sameABSPrincipal(a, sess.UserID, sess.ProfileID) {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if err := h.deps.PlaybackSessionStore.ClosePlaybackSession(r.Context(), sid); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.publish(a.UserID, "user_session_closed", map[string]any{
"id": sid,
"libraryItemId": sess.ContentID,
})
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Serialisation helpers
// ---------------------------------------------------------------------------
// progressRowToABS shapes a ProgressRow into the ABS /me/progress wire format.
// The `id` field uses the real-ABS convention of "<userID>-<libraryItemId>".
func progressRowToABS(p ProgressRow) map[string]any {
lastMs := p.UpdatedAt.UnixMilli()
out := map[string]any{
"id": p.UserID + "-" + p.ContentID,
"libraryItemId": p.ContentID,
"mediaItemId": p.ContentID,
"currentTime": p.CurrentSeconds,
"duration": p.DurationSeconds,
"isFinished": p.IsFinished,
"progress": p.ProgressPct,
"startedAt": lastMs,
"finishedAt": nil,
"lastUpdate": lastMs,
}
if p.IsFinished {
out["finishedAt"] = lastMs
}
return out
}
@@ -0,0 +1,20 @@
package abs
import (
"testing"
"time"
)
func TestProgressRowToABSEmitsDuration(t *testing.T) {
out := progressRowToABS(ProgressRow{
UserID: "u1",
ContentID: "b1",
CurrentSeconds: 30,
DurationSeconds: 3600,
ProgressPct: 0.0083,
UpdatedAt: time.Now(),
})
if out["duration"] != float64(3600) {
t.Errorf("duration = %v, want 3600", out["duration"])
}
}
+42
View File
@@ -0,0 +1,42 @@
package abs
import (
"context"
"time"
)
// RSSFeedStore is the storage contract for the abs_rss_feeds table.
type RSSFeedStore interface {
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
GetFeed(ctx context.Context, id string) (RSSFeed, error)
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
CreateFeed(ctx context.Context, f RSSFeed) error
CloseFeed(ctx context.Context, id string) error
}
// RSSFeed mirrors an abs_rss_feeds row.
type RSSFeed struct {
ID string
UserID string
ProfileID string
LibraryItemID string
Slug string
Minified bool
CreatedAt time.Time
ClosedAt *time.Time
}
// rssFeedToABS shapes a feed in the ABS wire format. `url` is built
// from the supplied base URL + slug.
func rssFeedToABS(f RSSFeed, baseURL string) map[string]any {
url := baseURL + "/feed/" + f.Slug + ".xml"
return map[string]any{
"id": f.ID,
"userId": f.UserID,
"libraryItemId": f.LibraryItemID,
"slug": f.Slug,
"minified": f.Minified,
"createdAt": f.CreatedAt.UnixMilli(),
"url": url,
}
}
@@ -0,0 +1,239 @@
package abs
import (
"crypto/rand"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/models"
)
var slugRe = regexp.MustCompile(`^[a-z0-9-]{4,64}$`)
type feedOpenBody struct {
Slug string `json:"slug"`
Minified bool `json:"minified"`
}
func (h *Handler) handleListRSSFeeds(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.RSSFeedStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"feeds": []any{}})
return
}
rows, err := h.deps.RSSFeedStore.ListUserFeeds(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs feed list failed", "err", err, "user", a.UserID)
http.Error(w, "feed list failed", http.StatusInternalServerError)
return
}
base := h.absBaseURL(r)
out := make([]map[string]any, 0, len(rows))
for _, f := range rows {
out = append(out, rssFeedToABS(f, base))
}
writeJSON(w, http.StatusOK, map[string]any{"feeds": out})
}
func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.RSSFeedStore == nil {
http.Error(w, "feed store unavailable", http.StatusServiceUnavailable)
return
}
itemID := chi.URLParam(r, "itemId")
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access)
if err != nil || item == nil {
http.Error(w, "item not found", http.StatusNotFound)
return
}
var body feedOpenBody
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
slug := strings.ToLower(strings.TrimSpace(body.Slug))
if slug == "" {
slug = randomSlug()
} else if !slugRe.MatchString(slug) {
http.Error(w, "invalid slug", http.StatusBadRequest)
return
}
f := RSSFeed{
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
LibraryItemID: itemID,
Slug: slug,
Minified: body.Minified,
}
if err := h.deps.RSSFeedStore.CreateFeed(r.Context(), f); err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
http.Error(w, "slug taken", http.StatusConflict)
return
}
slog.Error("abs feed create failed", "err", err, "user", a.UserID)
http.Error(w, "feed persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.RSSFeedStore.GetFeed(r.Context(), f.ID)
if errors.Is(err, ErrNotFound) || err != nil {
f.CreatedAt = time.Now()
persisted = f
}
writeJSON(w, http.StatusOK, rssFeedToABS(persisted, h.absBaseURL(r)))
}
func (h *Handler) handleCloseFeed(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.RSSFeedStore == nil {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
id := chi.URLParam(r, "id")
f, err := h.deps.RSSFeedStore.GetFeed(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, f.UserID, f.ProfileID)) {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs feed get-for-close failed", "err", err, "id", id)
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
if err := h.deps.RSSFeedStore.CloseFeed(r.Context(), id); err != nil {
slog.Error("abs feed close failed", "err", err, "id", id)
http.Error(w, "feed close failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func randomSlug() string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, 16)
_, _ = rand.Read(buf)
for i, b := range buf {
buf[i] = alphabet[int(b)%len(alphabet)]
}
return string(buf)
}
// handlePublicFeed — GET /feed/{slug}.xml and GET /feed/{slug}.
// Public, no auth. The slug is the capability token.
func (h *Handler) handlePublicFeed(w http.ResponseWriter, r *http.Request) {
if h.deps.RSSFeedStore == nil {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
slug := strings.TrimSuffix(chi.URLParam(r, "slug"), ".xml")
f, err := h.deps.RSSFeedStore.GetFeedBySlug(r.Context(), slug)
if errors.Is(err, ErrNotFound) || (err == nil && f.ClosedAt != nil) {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs public feed get failed", "err", err, "slug", slug)
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), f.LibraryItemID, emptyAccessFilter())
if err != nil || item == nil {
http.Error(w, "feed item not found", http.StatusNotFound)
return
}
files, _ := h.deps.MediaStore.GetMediaFiles(r.Context(), f.LibraryItemID, emptyAccessFilter())
base := h.absBaseURL(r)
xml := renderFeedXML(f, item, files, base)
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
_, _ = w.Write([]byte(xml))
}
// renderFeedXML builds a minimal RSS 2.0 + iTunes document.
func renderFeedXML(f RSSFeed, item *models.MediaItem, files []*models.MediaFile, baseURL string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">` + "\n")
b.WriteString("<channel>\n")
b.WriteString("<title>" + xmlEscape(item.Title) + "</title>\n")
b.WriteString("<link>" + xmlEscape(baseURL+"/feed/"+f.Slug+".xml") + "</link>\n")
b.WriteString("<description>silo audiobook feed</description>\n")
for _, mf := range files {
enc := baseURL + "/feed/" + f.Slug + "/file/" + strconv.Itoa(mf.ID)
b.WriteString("<item>\n")
b.WriteString("<title>" + xmlEscape(item.Title) + "</title>\n")
b.WriteString(`<enclosure url="` + xmlEscape(enc) + `" type="audio/mpeg" length="0"/>` + "\n")
b.WriteString("<guid>" + xmlEscape(f.Slug+"-"+strconv.Itoa(mf.ID)) + "</guid>\n")
b.WriteString("</item>\n")
}
b.WriteString("</channel>\n")
b.WriteString("</rss>\n")
return b.String()
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// handlePublicFeedFile — GET /feed/{slug}/file/{ino}. Streams the
// media file when the slug is valid + open and the ino belongs to
// the underlying library item.
func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
if h.deps.RSSFeedStore == nil {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
slug := chi.URLParam(r, "slug")
f, err := h.deps.RSSFeedStore.GetFeedBySlug(r.Context(), slug)
if errors.Is(err, ErrNotFound) || (err == nil && f.ClosedAt != nil) {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "feed get failed", http.StatusInternalServerError)
return
}
inoStr := chi.URLParam(r, "ino")
ino, parseErr := strconv.Atoi(inoStr)
if parseErr != nil {
http.Error(w, "invalid ino", http.StatusBadRequest)
return
}
mf, mfErr := h.deps.MediaStore.GetMediaFileByID(r.Context(), ino)
if mfErr != nil || mf == nil || mf.ContentID != f.LibraryItemID {
http.Error(w, "file not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, mf.FilePath)
}
@@ -0,0 +1,221 @@
package abs
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type memRSSFeedStore struct {
mu sync.Mutex
rows map[string]RSSFeed
}
func newMemRSSFeedStore() *memRSSFeedStore { return &memRSSFeedStore{rows: map[string]RSSFeed{}} }
func (m *memRSSFeedStore) ListUserFeeds(_ context.Context, userID, profileID string) ([]RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RSSFeed, 0)
for _, f := range m.rows {
if f.UserID == userID && f.ProfileID == profileID && f.ClosedAt == nil {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memRSSFeedStore) GetFeed(_ context.Context, id string) (RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
f, ok := m.rows[id]
if !ok {
return RSSFeed{}, ErrNotFound
}
return f, nil
}
func (m *memRSSFeedStore) GetFeedBySlug(_ context.Context, slug string) (RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, f := range m.rows {
if f.Slug == slug && f.ClosedAt == nil {
return f, nil
}
}
return RSSFeed{}, ErrNotFound
}
func (m *memRSSFeedStore) CreateFeed(_ context.Context, f RSSFeed) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, existing := range m.rows {
if existing.Slug == f.Slug && existing.ClosedAt == nil {
return errors.New("unique violation duplicate key")
}
}
f.CreatedAt = time.Now()
m.rows[f.ID] = f
return nil
}
func (m *memRSSFeedStore) CloseFeed(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
f, ok := m.rows[id]
if !ok {
return nil
}
now := time.Now()
f.ClosedAt = &now
m.rows[id] = f
return nil
}
func newFeedsHarness(t *testing.T, knownItems ...string) (*Handler, *memRSSFeedStore) {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
store := newMemRSSFeedStore()
h := New(Dependencies{MediaStore: &stubMediaStore{known: known}, RSSFeedStore: store})
return h, store
}
func TestFeed_Open_GeneratesSlug(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
slug, _ := got["slug"].(string)
if len(slug) != 16 {
t.Errorf("slug = %q (len %d), want 16-char auto-generated", slug, len(slug))
}
}
func TestFeed_Open_CustomSlug(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"my-cool-feed"}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["slug"] != "my-cool-feed" {
t.Errorf("slug = %v, want my-cool-feed", got["slug"])
}
}
func TestFeed_Open_InvalidSlug_400(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"BAD!"}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestFeed_Open_Collision_409(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
body := []byte(`{"slug":"taken-slug"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", rec.Code)
}
}
func TestFeed_Open_UnknownItem_404(t *testing.T) {
h, _ := newFeedsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/ghost/open",
map[string]string{"itemId": "ghost"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestFeed_List_OwnerOnly(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
rec := dispatchABSWithParams(http.MethodGet, "/api/feeds", nil, nil, "2", "", h.handleListRSSFeeds)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
feeds, _ := env["feeds"].([]any)
if len(feeds) != 0 {
t.Errorf("user 2 sees %d feeds, want 0", len(feeds))
}
}
func TestFeed_Close_Owner(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
var open map[string]any
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
id, _ := open["id"].(string)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "1", "", h.handleCloseFeed)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
}
func TestFeed_Close_NonOwner_404(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
var open map[string]any
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
id, _ := open["id"].(string)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "2", "", h.handleCloseFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPublicFeed_UnknownSlug_404(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodGet, "/feed/missing.xml",
map[string]string{"slug": "missing.xml"}, nil, "", "", h.handlePublicFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPublicFeed_HappyPath_XML(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"happy-feed"}`), "1", "", h.handleOpenItemFeed)
if openRec.Code != http.StatusOK {
t.Fatalf("seed open failed: %s", openRec.Body.String())
}
rec := dispatchABSWithParams(http.MethodGet, "/feed/happy-feed.xml",
map[string]string{"slug": "happy-feed.xml"}, nil, "", "", h.handlePublicFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
t.Errorf("Content-Type = %q, want application/rss+xml", ct)
}
body := rec.Body.String()
for _, needle := range []string{"<rss", "<channel>", "<title>"} {
if !strings.Contains(body, needle) {
t.Errorf("body missing %q; got %s", needle, body)
}
}
}
@@ -0,0 +1,62 @@
package abs
import (
"context"
"encoding/json"
"time"
)
// SmartCollectionStore is the narrow slice of user_personal_collections
// (collection_type='smart') the handlers need. Post-migration-156 smart
// collections share the unified canonical table; membership is computed
// at request time via the smartcoll package (no items table).
type SmartCollectionStore interface {
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
CreateSmartCollection(ctx context.Context, c SmartCollection) error
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
DeleteSmartCollection(ctx context.Context, id string) error
}
// SmartCollection mirrors a user_personal_collections row with
// collection_type='smart'. QueryDef holds the raw JSONB bytes from
// query_definition (decoded only on the /items route where rules are
// evaluated).
type SmartCollection struct {
ID string
UserID string
ProfileID string
Name string
Description string
Color string
IsPublic bool
IsPinned bool
QueryDef []byte
CreatedAt time.Time
UpdatedAt time.Time
}
// smartCollectionToABS shapes a SmartCollection in the ABS wire format.
// QueryDef is emitted as a nested JSON object (decoded once at
// serialisation time). Empty/nil QueryDef becomes the empty object.
func smartCollectionToABS(c SmartCollection) map[string]any {
var qd any = map[string]any{}
if len(c.QueryDef) > 0 {
var decoded any
if err := json.Unmarshal(c.QueryDef, &decoded); err == nil {
qd = decoded
}
}
return map[string]any{
"id": c.ID,
"userId": c.UserID,
"name": c.Name,
"description": c.Description,
"color": c.Color,
"isPublic": c.IsPublic,
"isPinned": c.IsPinned,
"queryDef": qd,
"createdAt": c.CreatedAt.UnixMilli(),
"updatedAt": c.UpdatedAt.UnixMilli(),
}
}
@@ -0,0 +1,48 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestSmartCollectionEnvelope_HasRequiredKeys(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
out := smartCollectionToABS(SmartCollection{
ID: "01HSC",
UserID: "1",
Name: "x",
Description: "",
Color: "",
IsPublic: false,
IsPinned: false,
QueryDef: []byte(`{"match":"all","groups":[]}`),
CreatedAt: now,
UpdatedAt: now,
})
body, _ := json.Marshal(out)
js := string(body)
for _, key := range []string{
`"id":`, `"userId":`, `"name":`, `"description":`, `"color":`,
`"isPublic":`, `"isPinned":`, `"queryDef":`, `"createdAt":`, `"updatedAt":`,
} {
if !strings.Contains(js, key) {
t.Errorf("envelope missing %s; got %s", key, js)
}
}
if _, ok := out["queryDef"].(map[string]any); !ok {
t.Errorf("queryDef should marshal as nested object, got %T: %v", out["queryDef"], out["queryDef"])
}
}
func TestSmartCollectionEnvelope_EmptyQueryDef(t *testing.T) {
out := smartCollectionToABS(SmartCollection{
ID: "x", UserID: "1", Name: "y",
QueryDef: nil,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
})
if _, has := out["queryDef"]; !has {
t.Errorf("queryDef missing when stored bytes are nil")
}
}
@@ -0,0 +1,416 @@
package abs
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"
"github.com/Silo-Server/silo-server/internal/models"
)
// smartCollectionBody is the JSON body for POST and PATCH
// /me/smart-collections[/{id}]. Pointer fields support partial PATCH.
type smartCollectionBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
Color *string `json:"color"`
IsPublic *bool `json:"isPublic"`
IsPinned *bool `json:"isPinned"`
QueryDef *smartcoll.QueryDefinition `json:"query_def"`
}
// handleCreateSmartCollection — POST /me/smart-collections.
func (h *Handler) handleCreateSmartCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
http.Error(w, "smart collection store unavailable", http.StatusServiceUnavailable)
return
}
var body smartCollectionBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name == nil || *body.Name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
c := SmartCollection{
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
Name: *body.Name,
}
if body.Description != nil {
c.Description = *body.Description
}
if body.Color != nil {
c.Color = *body.Color
}
if body.IsPublic != nil {
c.IsPublic = *body.IsPublic
}
if body.IsPinned != nil {
c.IsPinned = *body.IsPinned
}
qd := smartcoll.QueryDefinition{}
if body.QueryDef != nil {
qd = *body.QueryDef
}
qd = qd.Normalize()
if err := qd.Validate(true); err != nil {
http.Error(w, "invalid query_def: "+err.Error(), http.StatusBadRequest)
return
}
qdBytes, err := json.Marshal(qd)
if err != nil {
slog.Error("abs smart collection marshal query_def failed", "err", err)
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
return
}
c.QueryDef = qdBytes
if err := h.deps.SmartCollectionStore.CreateSmartCollection(r.Context(), c); err != nil {
slog.Error("abs smart collection create failed", "err", err, "user", a.UserID)
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), c.ID)
if errors.Is(err, ErrNotFound) || err != nil {
persisted = c
}
writeJSON(w, http.StatusOK, smartCollectionToABS(persisted))
}
func (h *Handler) handleListSmartCollections(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
writeJSON(w, http.StatusOK, map[string]any{"items": []any{}})
return
}
rows, err := h.deps.SmartCollectionStore.ListUserSmartCollections(r.Context(), a.UserID, a.ProfileID)
if err != nil {
slog.Error("abs smart collection list failed", "err", err, "user", a.UserID)
http.Error(w, "smart collection list failed", http.StatusInternalServerError)
return
}
out := make([]map[string]any, 0, len(rows))
for _, c := range rows {
out = append(out, smartCollectionToABS(c))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
func (h *Handler) handleGetSmartCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
c, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), chiURLID(r))
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID) && !c.IsPublic) {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs smart collection get failed", "err", err)
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, smartCollectionToABS(c))
}
func (h *Handler) handleUpdateSmartCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
c, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs smart collection get-for-update failed", "err", err, "id", id)
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
return
}
var body smartCollectionBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name != nil {
c.Name = *body.Name
}
if body.Description != nil {
c.Description = *body.Description
}
if body.Color != nil {
c.Color = *body.Color
}
if body.IsPublic != nil {
c.IsPublic = *body.IsPublic
}
if body.IsPinned != nil {
c.IsPinned = *body.IsPinned
}
if body.QueryDef != nil {
qd := body.QueryDef.Normalize()
if err := qd.Validate(true); err != nil {
http.Error(w, "invalid query_def: "+err.Error(), http.StatusBadRequest)
return
}
qdBytes, mErr := json.Marshal(qd)
if mErr != nil {
slog.Error("abs smart collection marshal query_def failed", "err", mErr)
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
return
}
c.QueryDef = qdBytes
}
if err := h.deps.SmartCollectionStore.UpdateSmartCollection(r.Context(), c); err != nil {
slog.Error("abs smart collection update failed", "err", err, "id", id)
http.Error(w, "smart collection persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), id)
if err != nil {
persisted = c
}
writeJSON(w, http.StatusOK, smartCollectionToABS(persisted))
}
func (h *Handler) handleDeleteSmartCollection(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
c, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID)) {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs smart collection get-for-delete failed", "err", err, "id", id)
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
return
}
if err := h.deps.SmartCollectionStore.DeleteSmartCollection(r.Context(), id); err != nil {
slog.Error("abs smart collection delete failed", "err", err, "id", id)
http.Error(w, "smart collection delete failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSmartCollectionItems — GET /me/smart-collections/{id}/items.
// Evaluates the collection's query_def against the audiobook catalog
// and returns a paged envelope. When the caller is the owner, per-user
// state is hydrated; non-owner viewing a public collection sees
// personalized rules silently dropped.
func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.SmartCollectionStore == nil {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
id := chiURLID(r)
c, err := h.deps.SmartCollectionStore.GetSmartCollection(r.Context(), id)
if errors.Is(err, ErrNotFound) || (err == nil && !sameABSPrincipal(a, c.UserID, c.ProfileID) && !c.IsPublic) {
http.Error(w, "smart collection not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("abs smart collection items get failed", "err", err, "id", id)
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
return
}
var qd smartcoll.QueryDefinition
if len(c.QueryDef) > 0 {
if uErr := json.Unmarshal(c.QueryDef, &qd); uErr != nil {
slog.Error("abs smart collection invalid stored query_def", "err", uErr, "id", id)
http.Error(w, "smart collection get failed", http.StatusInternalServerError)
return
}
}
qd = qd.Normalize()
limit, page := readPagedQuery(r, 30)
if r.URL.Query().Get("limit") == "" && qd.Limit != nil && *qd.Limit > 0 {
limit = *qd.Limit
}
access, err := h.accessFilterForAuth(r.Context(), a)
if err != nil {
http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden)
return
}
allLibs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access)
if err != nil {
slog.Warn("abs smart collection libraries fetch failed", "err", err, "id", id)
allLibs = nil
}
libByID := make(map[int64]AudiobookLibrary, len(allLibs))
for _, lib := range allLibs {
libByID[lib.ID] = lib
}
var targetLibs []AudiobookLibrary
if len(qd.LibraryIDs) > 0 {
for _, lid := range qd.LibraryIDs {
if lib, ok := libByID[lid]; ok {
targetLibs = append(targetLibs, lib)
}
}
} else {
targetLibs = allLibs
}
owner := sameABSPrincipal(a, c.UserID, c.ProfileID)
progressByID := map[string]ProgressRow{}
bookmarkCountByID := map[string]int{}
if owner {
if h.deps.ProgressStore != nil {
if rows, perr := h.deps.ProgressStore.ListProgressForAudiobooks(r.Context(), a.UserID, a.ProfileID, 10000); perr == nil {
for _, p := range rows {
progressByID[p.ContentID] = p
}
}
}
if h.deps.BookmarkStore != nil {
if counts, berr := h.deps.BookmarkStore.CountByUser(r.Context(), a.UserID, a.ProfileID); berr == nil {
bookmarkCountByID = counts
}
}
}
candidates := make([]smartcoll.Candidate, 0, 256)
for _, lib := range targetLibs {
items, _, lerr := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, 0, 0, access)
if lerr != nil {
slog.Warn("abs smart collection list-audiobooks failed", "err", lerr, "library", lib.ID)
continue
}
for _, mi := range items {
cand := smartcoll.Candidate{Item: siloItemToSmartcollItem(mi)}
if owner {
if p, ok := progressByID[mi.ContentID]; ok {
cand.IsFinished = p.IsFinished
cand.ProgressPct = float32(p.ProgressPct)
cand.CurrentSeconds = int(p.CurrentSeconds)
cand.LastPlayedAt = p.UpdatedAt
}
cand.BookmarkCount = bookmarkCountByID[mi.ContentID]
}
candidates = append(candidates, cand)
}
}
matched := smartcoll.Evaluate(r.Context(), qd, candidates, smartcoll.EvaluateOptions{
AllowPersonalized: owner,
UserSeed: a.UserID + ":" + c.ID,
})
total := len(matched)
start := page * limit
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
pageSlice := matched[start:end]
libDefault := h.resolveDefaultLibrary(r.Context(), access)
libDefaultID := audiobookLibraryID(libDefault)
results := make([]map[string]any, 0, len(pageSlice))
for _, cand := range pageSlice {
entry := map[string]any{
"id": cand.Item.ID,
"libraryId": libDefaultID,
"media": map[string]any{
"metadata": map[string]any{"title": cand.Item.Title},
},
}
results = append(results, entry)
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, qd.Sort.Field, qd.Sort.Order == "desc", "", false, ""))
}
// siloItemToSmartcollItem maps a silo *models.MediaItem into the
// audiobook-domain Item shape the smartcoll evaluator walks.
func siloItemToSmartcollItem(mi *models.MediaItem) smartcoll.Item {
if mi == nil {
return smartcoll.Item{}
}
it := smartcoll.Item{
ID: mi.ContentID,
Title: mi.Title,
Genres: mi.Genres,
Year: mi.Year,
Language: mi.OriginalLanguage,
DurationSeconds: mi.Runtime,
}
for _, p := range mi.People {
switch p.Kind {
case models.PersonKindAuthor:
it.Authors = append(it.Authors, p.Name)
case models.PersonKindNarrator:
it.Narrators = append(it.Narrators, p.Name)
}
}
for _, s := range mi.AudiobookSeries {
it.Series = append(it.Series, s.Name)
}
if len(mi.Studios) > 0 {
it.Publisher = mi.Studios[0]
}
if mi.RatingIMDB != nil {
it.Rating = *mi.RatingIMDB
}
if mi.AddedAt != nil {
it.AddedAt = *mi.AddedAt
}
return it
}
@@ -0,0 +1,385 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"sort"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type memSmartCollectionStore struct {
mu sync.Mutex
rows map[string]SmartCollection
}
func newMemSmartCollectionStore() *memSmartCollectionStore {
return &memSmartCollectionStore{rows: map[string]SmartCollection{}}
}
func (m *memSmartCollectionStore) ListUserSmartCollections(_ context.Context, userID, profileID string) ([]SmartCollection, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]SmartCollection, 0)
for _, c := range m.rows {
if c.UserID == userID && c.ProfileID == profileID {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memSmartCollectionStore) GetSmartCollection(_ context.Context, id string) (SmartCollection, error) {
m.mu.Lock()
defer m.mu.Unlock()
c, ok := m.rows[id]
if !ok {
return SmartCollection{}, ErrNotFound
}
return c, nil
}
func (m *memSmartCollectionStore) CreateSmartCollection(_ context.Context, c SmartCollection) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[c.ID] = c
return nil
}
func (m *memSmartCollectionStore) UpdateSmartCollection(_ context.Context, c SmartCollection) error {
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.rows[c.ID]
if !ok {
return ErrNotFound
}
existing.Name = c.Name
existing.Description = c.Description
existing.Color = c.Color
existing.IsPublic = c.IsPublic
existing.IsPinned = c.IsPinned
existing.QueryDef = c.QueryDef
existing.UpdatedAt = time.Now()
m.rows[c.ID] = existing
return nil
}
func (m *memSmartCollectionStore) DeleteSmartCollection(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, id)
return nil
}
type smartCollectionsHarness struct {
H *Handler
SC *memSmartCollectionStore
Prog *fakeProgressStore
Book *memBookmarkStore
}
func newSmartCollectionsHarness(t *testing.T, knownItems ...string) *smartCollectionsHarness {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = &models.MediaItem{ContentID: id, Title: "Title-" + id}
}
store := newMemSmartCollectionStore()
prog := &fakeProgressStore{}
book := newMemBookmarkStore()
h := New(Dependencies{
MediaStore: &itemListStubMediaStore{stubMediaStore: stubMediaStore{known: known}, items: itemListFromKnown(known)},
SmartCollectionStore: store,
ProgressStore: prog,
BookmarkStore: book,
})
return &smartCollectionsHarness{H: h, SC: store, Prog: prog, Book: book}
}
// itemListStubMediaStore extends stubMediaStore with ListAudiobooks +
// ListAudiobookLibraries so the smart-collection items handler can
// build candidates and resolve libraries.
type itemListStubMediaStore struct {
stubMediaStore
items []*models.MediaItem
}
func (s *itemListStubMediaStore) ListAudiobooks(_ context.Context, _ int64, _, _ int, _ catalog.AccessFilter) ([]*models.MediaItem, int, error) {
return s.items, len(s.items), nil
}
func (s *itemListStubMediaStore) ListAudiobookLibraries(_ context.Context, _ catalog.AccessFilter) ([]AudiobookLibrary, error) {
return []AudiobookLibrary{{ID: 9, Name: "Audiobooks", Type: "audiobooks"}}, nil
}
func itemListFromKnown(known map[string]*models.MediaItem) []*models.MediaItem {
out := make([]*models.MediaItem, 0, len(known))
for _, it := range known {
if it != nil {
out = append(out, it)
}
}
return out
}
func createSmartCollectionForUser(t *testing.T, hb *smartCollectionsHarness, userID, profileID, body string) string {
t.Helper()
rec := dispatchABSWithParams(http.MethodPost, "/api/me/smart-collections", nil, []byte(body), userID, profileID, hb.H.handleCreateSmartCollection)
if rec.Code != http.StatusOK {
t.Fatalf("seed POST status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
id, _ := got["id"].(string)
if id == "" {
t.Fatalf("seed POST returned no id; body=%s", rec.Body.String())
}
return id
}
func TestSmartCollection_Create_ReturnsFullShape(t *testing.T) {
hb := newSmartCollectionsHarness(t)
body := []byte(`{"name":"x","description":"d","color":"#fff","isPublic":true,"isPinned":true,"query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"title","op":"is","value":"test"}]}]}}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/me/smart-collections", nil, body, "1", "", hb.H.handleCreateSmartCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "x" {
t.Errorf("name = %v, want x", got["name"])
}
if got["isPublic"] != true {
t.Errorf("isPublic = %v, want true", got["isPublic"])
}
if got["isPinned"] != true {
t.Errorf("isPinned = %v, want true", got["isPinned"])
}
qd, ok := got["queryDef"].(map[string]any)
if !ok {
t.Fatalf("queryDef not nested object: %T %v", got["queryDef"], got["queryDef"])
}
if qd["match"] != "all" {
t.Errorf("queryDef.match = %v, want all", qd["match"])
}
}
func TestSmartCollection_Create_NameRequired_400(t *testing.T) {
hb := newSmartCollectionsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/me/smart-collections", nil, []byte(`{}`), "1", "", hb.H.handleCreateSmartCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestSmartCollection_Create_InvalidQueryDef_400(t *testing.T) {
hb := newSmartCollectionsHarness(t)
body := []byte(`{"name":"x","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"nonsense","op":"is","value":1}]}]}}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/me/smart-collections", nil, body, "1", "", hb.H.handleCreateSmartCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestSmartCollection_Create_InvalidBody_400(t *testing.T) {
hb := newSmartCollectionsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/me/smart-collections", nil, []byte(`{not json`), "1", "", hb.H.handleCreateSmartCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestSmartCollection_List_WrappedAsItems(t *testing.T) {
hb := newSmartCollectionsHarness(t)
_ = createSmartCollectionForUser(t, hb, "1", "", `{"name":"a"}`)
_ = createSmartCollectionForUser(t, hb, "1", "", `{"name":"b"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections", nil, nil, "1", "", hb.H.handleListSmartCollections)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, ok := env["items"].([]any)
if !ok {
t.Fatalf("response missing 'items' key (wrapped envelope); body=%s", rec.Body.String())
}
if len(list) != 2 {
t.Errorf("list len = %d, want 2", len(list))
}
}
func TestSmartCollection_List_DoesNotLeakOtherUsers(t *testing.T) {
hb := newSmartCollectionsHarness(t)
_ = createSmartCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections", nil, nil, "2", "", hb.H.handleListSmartCollections)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
list, _ := env["items"].([]any)
if len(list) != 0 {
t.Errorf("user 2 sees %d, want 0", len(list))
}
}
func TestSmartCollection_Get_Owner_ReturnsFullShape(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetSmartCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "mine" {
t.Errorf("name = %v", got["name"])
}
}
func TestSmartCollection_Get_NonOwner_Private_404(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"private"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetSmartCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestSmartCollection_Get_NonOwner_Public_OK(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetSmartCollection)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestSmartCollection_Get_Unknown_404(t *testing.T) {
hb := newSmartCollectionsHarness(t)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/01HZZZ", map[string]string{"id": "01HZZZ"}, nil, "1", "", hb.H.handleGetSmartCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestSmartCollection_Patch_OwnerUpdates(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"old"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/me/smart-collections/"+id, map[string]string{"id": id}, []byte(`{"name":"new","isPinned":true}`), "1", "", hb.H.handleUpdateSmartCollection)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "new" || got["isPinned"] != true {
t.Errorf("PATCH didn't apply: %v", got)
}
}
func TestSmartCollection_Patch_NonOwner_404(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/me/smart-collections/"+id, map[string]string{"id": id}, []byte(`{"name":"hijack"}`), "2", "", hb.H.handleUpdateSmartCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
c, _ := hb.SC.GetSmartCollection(context.Background(), id)
if c.Name != "mine" {
t.Errorf("non-owner leak: %q", c.Name)
}
}
func TestSmartCollection_Patch_InvalidQueryDef_400(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
body := []byte(`{"query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"nonsense","op":"is","value":1}]}]}}`)
rec := dispatchABSWithParams(http.MethodPatch, "/api/me/smart-collections/"+id, map[string]string{"id": id}, body, "1", "", hb.H.handleUpdateSmartCollection)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestSmartCollection_Delete_Owner_204(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"x"}`)
rec := dispatchABSWithParams(http.MethodDelete, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleDeleteSmartCollection)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
rec2 := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetSmartCollection)
if rec2.Code != http.StatusNotFound {
t.Errorf("post-delete GET status = %d, want 404", rec2.Code)
}
}
func TestSmartCollection_Delete_NonOwner_404(t *testing.T) {
hb := newSmartCollectionsHarness(t)
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"mine"}`)
rec := dispatchABSWithParams(http.MethodDelete, "/api/me/smart-collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleDeleteSmartCollection)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if _, err := hb.SC.GetSmartCollection(context.Background(), id); err != nil {
t.Errorf("non-owner DELETE leaked: %v", err)
}
}
func TestSmartCollection_Items_OwnerEvaluatesRules(t *testing.T) {
hb := newSmartCollectionsHarness(t, "book-a", "book-b", "book-c")
id := createSmartCollectionForUser(t, hb, "1", "",
`{"name":"a-only","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"title","op":"contains","value":"book-a"}]}]}}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id+"/items",
map[string]string{"id": id}, nil, "1", "", hb.H.handleSmartCollectionItems)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
results, _ := env["results"].([]any)
if len(results) != 1 {
t.Errorf("results len = %d, want 1; body=%s", len(results), rec.Body.String())
}
}
func TestSmartCollection_Items_PaginatedEnvelope(t *testing.T) {
hb := newSmartCollectionsHarness(t, "book-a", "book-b")
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"all"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id+"/items",
map[string]string{"id": id}, nil, "1", "", hb.H.handleSmartCollectionItems)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
for _, k := range []string{"results", "total", "limit", "page", "sortBy", "sortDesc", "filterBy", "minified", "include"} {
if _, has := env[k]; !has {
t.Errorf("envelope missing %q", k)
}
}
}
func TestSmartCollection_Items_NonOwnerPrivate_404(t *testing.T) {
hb := newSmartCollectionsHarness(t, "book-a")
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"private"}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id+"/items",
map[string]string{"id": id}, nil, "2", "", hb.H.handleSmartCollectionItems)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestSmartCollection_Items_NonOwnerPublic_OK(t *testing.T) {
hb := newSmartCollectionsHarness(t, "book-a")
id := createSmartCollectionForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodGet, "/api/me/smart-collections/"+id+"/items",
map[string]string{"id": id}, nil, "2", "", hb.H.handleSmartCollectionItems)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
+212
View File
@@ -0,0 +1,212 @@
package abs
// ABS wire-format constants. ServerVersion must be ≥ 2.26.0 for the official
// ABS mobile app to take its JWT path; below that it falls into "old token"
// mode and rejects modern refresh-token semantics.
// Ref: /opt/audiobookshelf-app/components/connect/ServerConnectForm.vue:731
const (
VirtualLibraryID = "silo-audiobooks"
VirtualLibraryName = "Audiobooks"
VirtualFolderID = "main"
LibraryMediaType = "book"
ServerVersion = "2.35.0"
ServerSourceTag = "silo"
)
// AuthorObj is the ABS-shaped author reference. ABS clients filter by id;
// some screens render only name.
type AuthorObj struct {
ID string `json:"id"`
Name string `json:"name"`
}
// SeriesObj is the ABS-shaped series reference; Sequence is the per-book
// position string (e.g. "1", "1.5").
type SeriesObj struct {
ID string `json:"id"`
Name string `json:"name"`
Sequence string `json:"sequence,omitempty"`
}
// ChapterABS is the ABS chapter shape (start/end in seconds, float).
type ChapterABS struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
}
// AudioTrackMetadata is the file-level metadata block nested inside each
// AudioTrack. The mobile downloader reads filename/ext to name the local
// copy, size to budget storage, and the mtime fields for cache invalidation.
type AudioTrackMetadata struct {
Filename string `json:"filename"`
Ext string `json:"ext"`
Path string `json:"path"`
RelPath string `json:"relPath"`
Size int64 `json:"size"`
MtimeMs int64 `json:"mtimeMs"`
CtimeMs int64 `json:"ctimeMs"`
BirthtimeMs int64 `json:"birthtimeMs"`
}
// AudioTrack is a single playable file as the ABS mobile client expects to
// see it. The shape is rich because the official audiobookshelf-app's Vue
// layer reads many fields off each track — ino + metadata for download URL
// construction and offline-cache decisions, bitRate / channels / codec /
// format for the "Now Playing" detail UI, embeddedCoverArt for whether to
// fall back to the item-level cover, metaTags for ID3-style display. A
// missing key on any of those code paths makes the player silently abort
// the audio load (the "spinner forever" we kept chasing before).
type AudioTrack struct {
Index int `json:"index"`
Ino string `json:"ino"`
Metadata *AudioTrackMetadata `json:"metadata,omitempty"`
AddedAt int64 `json:"addedAt,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
TrackNumFromMeta *int `json:"trackNumFromMeta"`
DiscNumFromMeta *int `json:"discNumFromMeta"`
TrackNumFromFilename *int `json:"trackNumFromFilename"`
DiscNumFromFilename *int `json:"discNumFromFilename"`
ManuallyVerified bool `json:"manuallyVerified"`
Exclude bool `json:"exclude"`
Error *string `json:"error"`
Format string `json:"format,omitempty"`
Duration float64 `json:"duration"`
BitRate int `json:"bitRate,omitempty"`
Language *string `json:"language"`
Codec string `json:"codec,omitempty"`
TimeBase string `json:"timeBase,omitempty"`
Channels int `json:"channels,omitempty"`
ChannelLayout string `json:"channelLayout,omitempty"`
Chapters []ChapterABS `json:"chapters,omitempty"`
EmbeddedCoverArt any `json:"embeddedCoverArt"`
MetaTags map[string]string `json:"metaTags,omitempty"`
MimeType string `json:"mimeType"`
Title string `json:"title,omitempty"`
StartOffset float64 `json:"startOffset"`
ContentURL string `json:"contentUrl"`
}
// Metadata is the book-level metadata block. Authors / Narrators / Series
// match the ABS spec: arrays of references (or strings for Narrators).
// Genres and Tags intentionally do NOT use omitempty — strict 3rd-party
// clients (Plappa, AudioBookShelfFully) branch on these keys being present
// (even if empty), and dropping the key sends them into degraded mode.
type Metadata struct {
Title string `json:"title"`
Authors []AuthorObj `json:"authors"`
Narrators []string `json:"narrators"`
Series []SeriesObj `json:"series"`
Description string `json:"description,omitempty"`
PublishedYear string `json:"publishedYear,omitempty"`
ISBN string `json:"isbn,omitempty"`
Publisher string `json:"publisher,omitempty"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
// Explicit is a content-warning flag the Kotlin BookMetadata declares
// as non-nullable Boolean. Always emit (default false). silo does not
// track per-item explicit metadata today; surface it when scanner-side
// support lands.
Explicit bool `json:"explicit"`
}
// LibraryItemMedia carries the bulk of the audiobook metadata.
//
// ABS distinguishes between audioFiles (file-level metadata) and tracks
// (the playback ordering the player iterates). For most audiobooks they're
// the same slice; we emit both because the item-detail page reads
// media.tracks.length to decide whether to render the play button, while
// card/list views read media.numTracks.
type LibraryItemMedia struct {
Metadata Metadata `json:"metadata"`
Duration float64 `json:"duration"`
CoverPath string `json:"coverPath"`
AudioFiles []AudioTrack `json:"audioFiles"`
Tracks []AudioTrack `json:"tracks"`
Chapters []ChapterABS `json:"chapters"`
NumTracks int `json:"numTracks"`
// Tags is a book-level tag list. NEVER null on the wire — the ABS
// Android client's Kotlin `Book.tags: List<String>` is non-nullable,
// so Jackson throws MissingKotlinParameterException when the field
// is absent (or null) and the entire LibraryItem fails to parse —
// which silently breaks downloads (the downloader's apiHandler
// callback receives null and gives up). Always emit [] in v1.
Tags []string `json:"tags"`
// EbookFile is the optional attached ebook (epub / pdf / mobi / cbz)
// reference. nil when no ebook is associated; the ABS web reader and
// mobile "Read Ebook" affordance both branch on the field's presence.
// Populated when the (future) ebook scanner extends the audiobook
// model; until then this is always nil on the wire (omitempty).
EbookFile *EbookFile `json:"ebookFile,omitempty"`
}
// EbookFile is the ABS-shape ebook reference. Mirrors the audiobookshelf
// server's BookMedia.ebookFile sub-record so the mobile clients accept it
// without translation. Filled in once silo's scanner detects ebook files
// alongside audiobooks.
type EbookFile struct {
Ino string `json:"ino"`
EbookFormat string `json:"ebookFormat"` // "epub", "pdf", "mobi", "cbz"
AddedAt int64 `json:"addedAt"`
UpdatedAt int64 `json:"updatedAt"`
MetadataPath string `json:"metadataPath,omitempty"`
Metadata struct {
Filename string `json:"filename"`
Ext string `json:"ext"`
Size int64 `json:"size"`
} `json:"metadata"`
}
// CollapsedSeriesV1 is the per-item annotation real ABS attaches when
// collapseseries=1. The shape is "name + count + per-book books[]"; we emit
// a stable subset since clients differ on which fields they read.
type CollapsedSeriesV1 struct {
ID string `json:"id"`
Name string `json:"name"`
NameIgnorePrefix string `json:"nameIgnorePrefix,omitempty"`
NumBooks int `json:"numBooks"`
LibraryItemIDs []string `json:"libraryItemIds"`
}
// LibraryItem is the ABS-shaped audiobook summary. AddedAt / UpdatedAt are
// Unix milliseconds; some shelves on the home screen sort by these and
// clients also expect them as ints (not strings).
//
// CollapsedSeries is non-nil only on items returned with collapseseries=1.
// It folds every book in a series into a single representative entry. ABS
// clients pattern-match on the presence of this field to switch from "list
// of books" to "list of series" UI.
//
// Ino / Path / RelPath / MtimeMs / CtimeMs / BirthtimeMs mirror fields the
// real-ABS filesystem-watcher emits at the item root. The ABS Android
// client's Kotlin LibraryItem declares all of these as non-nullable —
// jackson-module-kotlin's behaviour around missing primitives is lenient
// in some configurations but throws in stricter ones, so we always emit
// them with safe defaults (ID-derived ino, empty path strings, AddedAt
// echoed across the three time fields). Costs almost nothing on the wire
// and never causes a parser failure that silently breaks downloads.
type LibraryItem struct {
ID string `json:"id"`
Ino string `json:"ino"`
LibraryID string `json:"libraryId"`
FolderID string `json:"folderId"`
Path string `json:"path"`
RelPath string `json:"relPath"`
MtimeMs int64 `json:"mtimeMs"`
CtimeMs int64 `json:"ctimeMs"`
BirthtimeMs int64 `json:"birthtimeMs"`
MediaType string `json:"mediaType"`
// IsMissing / IsInvalid are gating fields the ABS mobile client checks
// before rendering the play affordance. We always emit them (no omitempty)
// so the client never sees them as undefined; the catalog we serve is by
// definition present and valid.
// Ref: /opt/audiobookshelf-app/pages/item/_id/index.vue:445
IsMissing bool `json:"isMissing"`
IsInvalid bool `json:"isInvalid"`
Media LibraryItemMedia `json:"media"`
NumTracks int `json:"numTracks,omitempty"`
AddedAt int64 `json:"addedAt"`
UpdatedAt int64 `json:"updatedAt"`
CollapsedSeries *CollapsedSeriesV1 `json:"collapsedSeries,omitempty"`
}
+160
View File
@@ -0,0 +1,160 @@
package audiobooks
import (
"context"
"fmt"
"strconv"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSBookmarkStore implements abs.BookmarkStore against the
// abs_bookmarks table (migration 148). One row per
// (user, profile, item, time) — uniqueness is enforced by the
// abs_bookmarks_user_profile_item_time_uniq index, with the
// COALESCE-to-sentinel-UUID trick collapsing NULL profile_id into a
// single bucket per user.
type ABSBookmarkStore struct {
Pool *pgxpool.Pool
}
// Compile-time assertion that ABSBookmarkStore satisfies the
// abs.BookmarkStore contract. Catches signature drift at build time.
var _ abs.BookmarkStore = (*ABSBookmarkStore)(nil)
// profileArg returns the value to bind for the profile_id column.
// pgx interprets a (*string)(nil) as SQL NULL, which is exactly what
// the schema wants for primary-profile rows.
func profileArg(profileID string) any {
if profileID == "" {
return nil
}
return profileID
}
// List returns all bookmarks for (user, profile, item) ordered by
// time_seconds ASC. Empty slice (never nil) when none exist.
func (s *ABSBookmarkStore) List(ctx context.Context, userID, profileID, itemID string) ([]abs.Bookmark, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, library_item_id, time_seconds, title, created_at, updated_at
FROM abs_bookmarks
WHERE user_id = $1
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
AND library_item_id = $3
ORDER BY time_seconds ASC`,
uid, profileArg(profileID), itemID,
)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.Bookmark, 0)
for rows.Next() {
var b abs.Bookmark
if err := rows.Scan(&b.ID, &b.LibraryItemID, &b.Time, &b.Title, &b.CreatedAt, &b.UpdatedAt); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list scan: %w", err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: list rows: %w", err)
}
return out, nil
}
// Upsert inserts a new bookmark or updates the title at the exact
// (user, profile, item, time) tuple. ID is generated on insert and
// preserved on update.
func (s *ABSBookmarkStore) Upsert(ctx context.Context, userID, profileID, itemID string, timeSeconds float64, title string) (abs.Bookmark, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return abs.Bookmark{}, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
id := ulid.Make().String()
var out abs.Bookmark
row := s.Pool.QueryRow(ctx, `
INSERT INTO abs_bookmarks
(id, user_id, profile_id, library_item_id, time_seconds, title)
VALUES ($1, $2, $3::uuid, $4, $5, $6)
ON CONFLICT (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_item_id,
time_seconds
) DO UPDATE
SET title = EXCLUDED.title,
updated_at = now()
RETURNING id, library_item_id, time_seconds, title, created_at, updated_at`,
id, uid, profileArg(profileID), itemID, timeSeconds, title,
)
if err := row.Scan(&out.ID, &out.LibraryItemID, &out.Time, &out.Title, &out.CreatedAt, &out.UpdatedAt); err != nil {
return abs.Bookmark{}, fmt.Errorf("abs_bookmark_store: upsert: %w", err)
}
return out, nil
}
// Delete removes the bookmark at (user, profile, item, time).
// Returns nil when no row matched — DELETE is idempotent per
// the BookmarkStore contract.
func (s *ABSBookmarkStore) Delete(ctx context.Context, userID, profileID, itemID string, timeSeconds float64) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
if _, err := s.Pool.Exec(ctx, `
DELETE FROM abs_bookmarks
WHERE user_id = $1
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
AND library_item_id = $3
AND time_seconds = $4`,
uid, profileArg(profileID), itemID, timeSeconds,
); err != nil {
return fmt.Errorf("abs_bookmark_store: delete: %w", err)
}
return nil
}
// CountByUser returns a map of library_item_id -> bookmark count for
// the given (user, profile). One SQL query; used by the
// smart-collection items evaluator for batch hydration.
func (s *ABSBookmarkStore) CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT library_item_id, COUNT(*)
FROM abs_bookmarks
WHERE user_id = $1
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
GROUP BY library_item_id`,
uid, profileArg(profileID),
)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user: %w", err)
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var itemID string
var count int
if err := rows.Scan(&itemID, &count); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user scan: %w", err)
}
out[itemID] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user rows: %w", err)
}
return out, nil
}
+236
View File
@@ -0,0 +1,236 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSCollectionStore implements abs.CollectionStore against the canonical
// user_personal_collections + user_personal_collection_items tables
// (migration 156). Manual ABS collections live in user_personal_collections
// with collection_type = 'manual'; their member books are rows in
// user_personal_collection_items with sub_item_id = ” (the empty string
// distinguishes whole-item membership from playlist podcast-episode rows).
//
// abs.Collection.IsPublic maps to user_personal_collections.is_shared.
// profile_id is a text column (NOT NULL DEFAULT ”) in the canonical
// schema, so the empty string stands in for "primary profile".
type ABSCollectionStore struct {
Pool *pgxpool.Pool
}
// Compile-time assertion.
var _ abs.CollectionStore = (*ABSCollectionStore)(nil)
// absCollectionTypeManual is the discriminator value for ABS manual
// collections in the canonical user_personal_collections table.
const absCollectionTypeManual = "manual"
func (s *ABSCollectionStore) ListUserCollections(ctx context.Context, userID, profileID string) ([]abs.Collection, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_collection_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = $3
AND user_id = $1
AND profile_id = $2
ORDER BY created_at DESC`,
uid, profileID, absCollectionTypeManual,
)
if err != nil {
return nil, fmt.Errorf("abs_collection_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.Collection, 0)
for rows.Next() {
var c abs.Collection
var uidScan int
if err := rows.Scan(&c.ID, &uidScan, &c.ProfileID, &c.Name, &c.Description, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("abs_collection_store: list scan: %w", err)
}
c.UserID = strconv.Itoa(uidScan)
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_collection_store: list rows: %w", err)
}
return out, nil
}
func (s *ABSCollectionStore) GetCollection(ctx context.Context, id string) (abs.Collection, error) {
var c abs.Collection
var uidScan int
row := s.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypeManual,
)
if err := row.Scan(&c.ID, &uidScan, &c.ProfileID, &c.Name, &c.Description, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.Collection{}, abs.ErrNotFound
}
return abs.Collection{}, fmt.Errorf("abs_collection_store: get: %w", err)
}
c.UserID = strconv.Itoa(uidScan)
return c, nil
}
func (s *ABSCollectionStore) CreateCollection(ctx context.Context, c abs.Collection) error {
uid, err := strconv.Atoi(c.UserID)
if err != nil {
return fmt.Errorf("abs_collection_store: invalid user id %q: %w", c.UserID, err)
}
if _, err := s.Pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, creator_profile_id, name, description,
collection_type, is_shared, query_definition, created_at, updated_at)
VALUES ($1, $2, $3, $3, $4, $5, $6, $7, '{}'::jsonb, now(), now())`,
c.ID, uid, c.ProfileID, c.Name, c.Description, absCollectionTypeManual, c.IsPublic,
); err != nil {
return fmt.Errorf("abs_collection_store: create: %w", err)
}
return nil
}
func (s *ABSCollectionStore) UpdateCollection(ctx context.Context, c abs.Collection) error {
if _, err := s.Pool.Exec(ctx, `
UPDATE user_personal_collections
SET name = $2, description = $3, is_shared = $4, updated_at = now()
WHERE id = $1 AND collection_type = $5`,
c.ID, c.Name, c.Description, c.IsPublic, absCollectionTypeManual,
); err != nil {
return fmt.Errorf("abs_collection_store: update: %w", err)
}
return nil
}
func (s *ABSCollectionStore) DeleteCollection(ctx context.Context, id string) error {
// user_personal_collection_items has no FK to user_personal_collections,
// so cascade is not automatic — drop items first, then the parent.
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_collection_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collection_items WHERE collection_id = $1`,
id,
); err != nil {
return fmt.Errorf("abs_collection_store: delete-items: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collections WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypeManual,
); err != nil {
return fmt.Errorf("abs_collection_store: delete: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_collection_store: commit: %w", err)
}
return nil
}
func (s *ABSCollectionStore) ListCollectionItems(ctx context.Context, collectionID string) ([]abs.CollectionItem, error) {
rows, err := s.Pool.Query(ctx, `
SELECT media_item_id, added_at
FROM user_personal_collection_items
WHERE collection_id = $1 AND sub_item_id = ''
ORDER BY position ASC, added_at ASC`,
collectionID,
)
if err != nil {
return nil, fmt.Errorf("abs_collection_store: list-items: %w", err)
}
defer rows.Close()
out := make([]abs.CollectionItem, 0)
for rows.Next() {
var it abs.CollectionItem
if err := rows.Scan(&it.LibraryItemID, &it.AddedAt); err != nil {
return nil, fmt.Errorf("abs_collection_store: list-items scan: %w", err)
}
it.CollectionID = collectionID
out = append(out, it)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_collection_store: list-items rows: %w", err)
}
return out, nil
}
func (s *ABSCollectionStore) AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_collection_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
// user_personal_collection_items.user_id is NOT NULL and has no
// default; the canonical schema requires the inserter to repeat the
// owning user_id from the parent. INSERT ... SELECT preserves the
// silent-no-op semantics of the ABS interface when the parent is
// missing (zero rows selected → zero rows inserted) and lets the
// PK ON CONFLICT keep re-adds idempotent.
if _, err := tx.Exec(ctx, `
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT c.user_id, c.id, $2, '',
COALESCE((
SELECT MAX(i.position) + 1
FROM user_personal_collection_items i
WHERE i.collection_id = c.id
), 0),
now()
FROM user_personal_collections c
WHERE c.id = $1 AND c.collection_type = $3
ON CONFLICT (user_id, collection_id, media_item_id, sub_item_id) DO NOTHING`,
collectionID, libraryItemID, absCollectionTypeManual,
); err != nil {
return fmt.Errorf("abs_collection_store: add-item: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE user_personal_collections SET updated_at = now() WHERE id = $1 AND collection_type = $2`,
collectionID, absCollectionTypeManual,
); err != nil {
return fmt.Errorf("abs_collection_store: bump-parent: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_collection_store: commit: %w", err)
}
return nil
}
func (s *ABSCollectionStore) RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_collection_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collection_items
WHERE collection_id = $1 AND media_item_id = $2 AND sub_item_id = ''`,
collectionID, libraryItemID,
); err != nil {
return fmt.Errorf("abs_collection_store: remove-item: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE user_personal_collections SET updated_at = now() WHERE id = $1 AND collection_type = $2`,
collectionID, absCollectionTypeManual,
); err != nil {
return fmt.Errorf("abs_collection_store: bump-parent: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_collection_store: commit: %w", err)
}
return nil
}
@@ -0,0 +1,269 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSPlaybackSessionStore implements abs.ABSPlaybackSessionStore on the
// abs_playback_sessions table (migration 143). Each row tracks one
// /abs/api/items/{id}/play session opened by an ABS-compatible client
// and is closed (closed_at set) when the client calls /session/{sid}/close.
type ABSPlaybackSessionStore struct {
Pool *pgxpool.Pool
}
// InsertPlaybackSession persists a new session row at play-start time.
func (s *ABSPlaybackSessionStore) InsertPlaybackSession(ctx context.Context, sess abs.ABSPlaybackSession) error {
uid, err := strconv.Atoi(sess.UserID)
if err != nil {
return fmt.Errorf("abs_playback_session_store: invalid user_id %q: %w", sess.UserID, err)
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO abs_playback_sessions
(id, user_id, profile_id, content_id, media_file_id,
started_at, last_sync_at, time_listening_seconds, current_position_seconds)
VALUES ($1, $2, $3, $4, $5, now(), now(), 0, $6)
ON CONFLICT (id) DO NOTHING`,
sess.ID,
uid,
sess.ProfileID,
sess.ContentID,
sess.MediaFileID,
sess.CurrentPositionSeconds,
)
if err != nil {
return fmt.Errorf("abs_playback_session_store: insert: %w", err)
}
return nil
}
// GetPlaybackSession fetches a session by its ULID. Returns abs.ErrNotFound
// when the row doesn't exist.
func (s *ABSPlaybackSessionStore) GetPlaybackSession(ctx context.Context, id string) (abs.ABSPlaybackSession, error) {
var sess abs.ABSPlaybackSession
var uid int
var profileID string
var closedAt *time.Time
row := s.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, content_id,
time_listening_seconds, current_position_seconds,
started_at, last_sync_at, closed_at
FROM abs_playback_sessions
WHERE id = $1`, id)
err := row.Scan(
&sess.ID, &uid, &profileID, &sess.ContentID,
&sess.TimeListeningSeconds, &sess.CurrentPositionSeconds,
&sess.StartedAt, &sess.LastSyncAt, &closedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return abs.ABSPlaybackSession{}, abs.ErrNotFound
}
if err != nil {
return abs.ABSPlaybackSession{}, fmt.Errorf("abs_playback_session_store: get: %w", err)
}
sess.UserID = strconv.Itoa(uid)
sess.ProfileID = profileID
sess.ClosedAt = closedAt
return sess, nil
}
// SyncPlaybackSession updates the position and accumulated listening time for
// an open session. Idempotent: calling it on an already-closed session is
// a no-op (the WHERE closed_at IS NULL guard prevents overwriting a final
// state with a stale sync payload that arrives after close).
func (s *ABSPlaybackSessionStore) SyncPlaybackSession(ctx context.Context, id string, currentPositionSeconds float64, timeListeningSeconds int) error {
_, err := s.Pool.Exec(ctx, `
UPDATE abs_playback_sessions
SET current_position_seconds = $2,
time_listening_seconds = time_listening_seconds + $3,
last_sync_at = now()
WHERE id = $1 AND closed_at IS NULL`,
id, currentPositionSeconds, timeListeningSeconds,
)
if err != nil {
return fmt.Errorf("abs_playback_session_store: sync: %w", err)
}
return nil
}
// ClosePlaybackSession sets closed_at = now() for the given session.
// Idempotent: closing an already-closed session is safe.
func (s *ABSPlaybackSessionStore) ClosePlaybackSession(ctx context.Context, id string) error {
_, err := s.Pool.Exec(ctx, `
UPDATE abs_playback_sessions
SET closed_at = now()
WHERE id = $1 AND closed_at IS NULL`,
id,
)
if err != nil {
return fmt.Errorf("abs_playback_session_store: close: %w", err)
}
return nil
}
func (s *ABSPlaybackSessionStore) CloseOpenSessionsForPrincipal(ctx context.Context, userID, profileID string) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_playback_session_store: invalid user id %q: %w", userID, err)
}
_, err = s.Pool.Exec(ctx, `
UPDATE abs_playback_sessions
SET closed_at = now()
WHERE user_id = $1 AND profile_id = $2 AND closed_at IS NULL`,
uid, profileID,
)
if err != nil {
return fmt.Errorf("abs_playback_session_store: close principal sessions: %w", err)
}
return nil
}
// AggregateStats returns the aggregated /me/listening-stats payload.
func (s *ABSPlaybackSessionStore) AggregateStats(ctx context.Context, userID, profileID string) (abs.Stats, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: invalid user id %q: %w", userID, err)
}
out := abs.Stats{Days: []abs.DayStat{}, Monthly: []abs.MonthStat{}}
row := s.Pool.QueryRow(ctx, `
SELECT COALESCE(SUM(time_listening_seconds), 0), COUNT(DISTINCT content_id)
FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2`,
uid, profileID,
)
if err := row.Scan(&out.TotalTime, &out.Items); err != nil {
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats totals: %w", err)
}
rows, err := s.Pool.Query(ctx, `
SELECT TO_CHAR(date_trunc('day', started_at), 'YYYY-MM-DD'),
COALESCE(SUM(time_listening_seconds), 0)
FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2
AND started_at >= now() - INTERVAL '30 days'
GROUP BY 1
ORDER BY 1 DESC`,
uid, profileID,
)
if err != nil {
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats days: %w", err)
}
for rows.Next() {
var d abs.DayStat
if scanErr := rows.Scan(&d.Date, &d.Seconds); scanErr != nil {
rows.Close()
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats days scan: %w", scanErr)
}
out.Days = append(out.Days, d)
}
rows.Close()
dowRows, err := s.Pool.Query(ctx, `
SELECT EXTRACT(DOW FROM started_at)::int,
COALESCE(SUM(time_listening_seconds), 0)
FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2
GROUP BY 1`,
uid, profileID,
)
if err != nil {
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats dow: %w", err)
}
for dowRows.Next() {
var dow, secs int
if scanErr := dowRows.Scan(&dow, &secs); scanErr != nil {
dowRows.Close()
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats dow scan: %w", scanErr)
}
if dow >= 0 && dow < 7 {
out.DayOfWeek[dow] = secs
}
}
dowRows.Close()
mRows, err := s.Pool.Query(ctx, `
SELECT TO_CHAR(date_trunc('month', started_at), 'YYYY-MM'),
COALESCE(SUM(time_listening_seconds), 0)
FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2
AND started_at >= now() - INTERVAL '12 months'
GROUP BY 1
ORDER BY 1 DESC`,
uid, profileID,
)
if err != nil {
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats months: %w", err)
}
for mRows.Next() {
var m abs.MonthStat
if scanErr := mRows.Scan(&m.Month, &m.Seconds); scanErr != nil {
mRows.Close()
return abs.Stats{}, fmt.Errorf("abs_playback_session_store: stats months scan: %w", scanErr)
}
out.Monthly = append(out.Monthly, m)
}
mRows.Close()
return out, nil
}
func (s *ABSPlaybackSessionStore) ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]abs.ABSPlaybackSession, int, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, 0, fmt.Errorf("abs_playback_session_store: invalid user id %q: %w", userID, err)
}
if limit <= 0 || limit > 200 {
limit = 30
}
if offset < 0 {
offset = 0
}
var total int
if err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2 AND closed_at IS NOT NULL`,
uid, profileID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("abs_playback_session_store: list closed count: %w", err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, user_id, profile_id, content_id,
time_listening_seconds, current_position_seconds, closed_at
FROM abs_playback_sessions
WHERE user_id = $1 AND profile_id = $2 AND closed_at IS NOT NULL
ORDER BY started_at DESC
LIMIT $3 OFFSET $4`,
uid, profileID, limit, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("abs_playback_session_store: list closed: %w", err)
}
defer rows.Close()
out := make([]abs.ABSPlaybackSession, 0, limit)
for rows.Next() {
var sess abs.ABSPlaybackSession
var scanUID int
var scanProfile string
var closedAt *time.Time
if err := rows.Scan(&sess.ID, &scanUID, &scanProfile, &sess.ContentID, &sess.TimeListeningSeconds, &sess.CurrentPositionSeconds, &closedAt); err != nil {
return nil, 0, fmt.Errorf("abs_playback_session_store: list closed scan: %w", err)
}
sess.UserID = strconv.Itoa(scanUID)
sess.ProfileID = scanProfile
sess.ClosedAt = closedAt
out = append(out, sess)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("abs_playback_session_store: list closed rows: %w", err)
}
return out, total, nil
}
+247
View File
@@ -0,0 +1,247 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSPlaylistStore implements abs.PlaylistStore against the canonical
// user_personal_collections + user_personal_collection_items tables
// (migration 156). ABS playlists live in user_personal_collections with
// collection_type = 'playlist'; their entries are rows in
// user_personal_collection_items where sub_item_id is the ABS
// episode_id (empty string for whole-book entries, non-empty for
// podcast-episode entries).
//
// abs.Playlist.IsPublic maps to user_personal_collections.is_shared.
// profile_id is a text column (NOT NULL DEFAULT ”) in the canonical
// schema, so the empty string stands in for "primary profile".
//
// abs.Playlist.CoverItem has no canonical column (deferred per
// spec §6); reads always return the zero value and writes ignore it.
type ABSPlaylistStore struct {
Pool *pgxpool.Pool
}
var _ abs.PlaylistStore = (*ABSPlaylistStore)(nil)
// absCollectionTypePlaylist is the discriminator value for ABS
// playlists in the canonical user_personal_collections table.
const absCollectionTypePlaylist = "playlist"
func (s *ABSPlaylistStore) ListUserPlaylists(ctx context.Context, userID, profileID string) ([]abs.Playlist, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_playlist_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = $3
AND user_id = $1
AND profile_id = $2
ORDER BY created_at DESC`,
uid, profileID, absCollectionTypePlaylist,
)
if err != nil {
return nil, fmt.Errorf("abs_playlist_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.Playlist, 0)
for rows.Next() {
var p abs.Playlist
var uidScan int
if err := rows.Scan(&p.ID, &uidScan, &p.ProfileID, &p.Name, &p.Description, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("abs_playlist_store: list scan: %w", err)
}
p.UserID = strconv.Itoa(uidScan)
// CoverItem has no canonical column — always zero.
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_playlist_store: list rows: %w", err)
}
return out, nil
}
func (s *ABSPlaylistStore) GetPlaylist(ctx context.Context, id string) (abs.Playlist, error) {
var p abs.Playlist
var uidScan int
row := s.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypePlaylist,
)
if err := row.Scan(&p.ID, &uidScan, &p.ProfileID, &p.Name, &p.Description, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.Playlist{}, abs.ErrNotFound
}
return abs.Playlist{}, fmt.Errorf("abs_playlist_store: get: %w", err)
}
p.UserID = strconv.Itoa(uidScan)
// CoverItem has no canonical column — always zero.
return p, nil
}
func (s *ABSPlaylistStore) CreatePlaylist(ctx context.Context, p abs.Playlist) error {
uid, err := strconv.Atoi(p.UserID)
if err != nil {
return fmt.Errorf("abs_playlist_store: invalid user id %q: %w", p.UserID, err)
}
// CoverItem is intentionally not persisted (no canonical column).
if _, err := s.Pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, creator_profile_id, name, description,
collection_type, is_shared, query_definition, created_at, updated_at)
VALUES ($1, $2, $3, $3, $4, $5, $6, $7, '{}'::jsonb, now(), now())`,
p.ID, uid, p.ProfileID, p.Name, p.Description, absCollectionTypePlaylist, p.IsPublic,
); err != nil {
return fmt.Errorf("abs_playlist_store: create: %w", err)
}
return nil
}
func (s *ABSPlaylistStore) UpdatePlaylist(ctx context.Context, p abs.Playlist) error {
// CoverItem is intentionally not persisted (no canonical column).
if _, err := s.Pool.Exec(ctx, `
UPDATE user_personal_collections
SET name = $2, description = $3, is_shared = $4, updated_at = now()
WHERE id = $1 AND collection_type = $5`,
p.ID, p.Name, p.Description, p.IsPublic, absCollectionTypePlaylist,
); err != nil {
return fmt.Errorf("abs_playlist_store: update: %w", err)
}
return nil
}
func (s *ABSPlaylistStore) DeletePlaylist(ctx context.Context, id string) error {
// user_personal_collection_items has no FK to user_personal_collections,
// so cascade is not automatic — drop items first, then the parent.
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_playlist_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collection_items WHERE collection_id = $1`,
id,
); err != nil {
return fmt.Errorf("abs_playlist_store: delete-items: %w", err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collections WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypePlaylist,
); err != nil {
return fmt.Errorf("abs_playlist_store: delete: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_playlist_store: commit: %w", err)
}
return nil
}
func (s *ABSPlaylistStore) ListPlaylistItems(ctx context.Context, playlistID string) ([]abs.PlaylistItem, error) {
rows, err := s.Pool.Query(ctx, `
SELECT media_item_id, COALESCE(sub_item_id, '') AS episode_id, position, added_at
FROM user_personal_collection_items
WHERE collection_id = $1
ORDER BY position ASC, added_at ASC`,
playlistID,
)
if err != nil {
return nil, fmt.Errorf("abs_playlist_store: list-items: %w", err)
}
defer rows.Close()
out := make([]abs.PlaylistItem, 0)
for rows.Next() {
var it abs.PlaylistItem
if err := rows.Scan(&it.LibraryItemID, &it.EpisodeID, &it.Position, &it.AddedAt); err != nil {
return nil, fmt.Errorf("abs_playlist_store: list-items scan: %w", err)
}
it.PlaylistID = playlistID
out = append(out, it)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_playlist_store: list-items rows: %w", err)
}
return out, nil
}
func (s *ABSPlaylistStore) AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_playlist_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
// user_personal_collection_items.user_id is NOT NULL and has no
// default; copy it from the parent playlist row. INSERT ... SELECT
// preserves the silent-no-op semantics of the ABS interface when
// the parent is missing (zero rows selected → zero rows inserted)
// and the PK ON CONFLICT keeps re-adds idempotent.
//
// See the top-of-file note on the PK-collision caveat: episode_id
// (sub_item_id) is NOT in the PK, so a second add of the same
// (collection_id, media_item_id) with a different episode_id will
// be silently dropped by ON CONFLICT.
if _, err := tx.Exec(ctx, `
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT c.user_id, c.id, $2, $3,
COALESCE((
SELECT MAX(i.position) + 1
FROM user_personal_collection_items i
WHERE i.collection_id = c.id
), 0),
now()
FROM user_personal_collections c
WHERE c.id = $1 AND c.collection_type = $4
ON CONFLICT (user_id, collection_id, media_item_id, sub_item_id) DO NOTHING`,
playlistID, libraryItemID, episodeID, absCollectionTypePlaylist,
); err != nil {
return fmt.Errorf("abs_playlist_store: add-item: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE user_personal_collections SET updated_at = now() WHERE id = $1 AND collection_type = $2`,
playlistID, absCollectionTypePlaylist,
); err != nil {
return fmt.Errorf("abs_playlist_store: bump-parent: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_playlist_store: commit: %w", err)
}
return nil
}
func (s *ABSPlaylistStore) RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("abs_playlist_store: begin tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx,
`DELETE FROM user_personal_collection_items
WHERE collection_id = $1 AND media_item_id = $2 AND sub_item_id = $3`,
playlistID, libraryItemID, episodeID,
); err != nil {
return fmt.Errorf("abs_playlist_store: remove-item: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE user_personal_collections SET updated_at = now() WHERE id = $1 AND collection_type = $2`,
playlistID, absCollectionTypePlaylist,
); err != nil {
return fmt.Errorf("abs_playlist_store: bump-parent: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("abs_playlist_store: commit: %w", err)
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSProgressStore implements abs.ProgressStore directly against the
// user_watch_progress table using a shared pgxpool. Using the pool directly
// (rather than the per-user-scoped PostgresUserStore) lets us query by
// (user_id, profile_id) without needing a ForUser call, which would require
// knowing the integer user_id at construction time. The ABS handlers carry
// user_id as a string and resolve it inline here.
type ABSProgressStore struct {
Pool *pgxpool.Pool
}
var _ abs.ProgressStore = (*ABSProgressStore)(nil)
// GetProgress returns the progress row for (userID, profileID, contentID).
// Returns (nil, nil) when no row exists.
func (s *ABSProgressStore) GetProgress(ctx context.Context, userID, profileID, contentID string) (*abs.ProgressRow, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
}
var row abs.ProgressRow
var updatedAt time.Time
var positionSeconds, durationSeconds float64
var completed bool
var progressPct *float64
dbRow := s.Pool.QueryRow(ctx, `
SELECT media_item_id, position_seconds, duration_seconds, completed,
CASE WHEN duration_seconds > 0 THEN position_seconds / duration_seconds ELSE 0 END AS progress_pct,
updated_at
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
uid, profileID, contentID,
)
err = dbRow.Scan(
&row.ContentID, &positionSeconds, &durationSeconds, &completed,
&progressPct, &updatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("abs_progress_store: get progress: %w", err)
}
row.UserID = userID
row.ProfileID = profileID
row.CurrentSeconds = positionSeconds
row.DurationSeconds = durationSeconds
row.IsFinished = completed
if progressPct != nil {
row.ProgressPct = *progressPct
}
row.UpdatedAt = updatedAt
return &row, nil
}
// ListProgressForAudiobooks returns all progress rows for (userID, profileID)
// that join to media_items with type = 'audiobook'. Ordered by updated_at DESC,
// capped at limit rows.
func (s *ABSProgressStore) ListProgressForAudiobooks(ctx context.Context, userID, profileID string, limit int) ([]abs.ProgressRow, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
}
if limit <= 0 {
limit = 500
}
rows, err := s.Pool.Query(ctx, `
SELECT wp.media_item_id,
wp.position_seconds,
wp.duration_seconds,
wp.completed,
CASE WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds ELSE 0 END,
wp.updated_at
FROM user_watch_progress wp
JOIN media_items mi ON mi.content_id = wp.media_item_id
WHERE wp.user_id = $1
AND wp.profile_id = $2
AND mi.type = 'audiobook'
ORDER BY wp.updated_at DESC
LIMIT $3`,
uid, profileID, limit,
)
if err != nil {
return nil, fmt.Errorf("abs_progress_store: list progress: %w", err)
}
defer rows.Close()
var result []abs.ProgressRow
for rows.Next() {
var p abs.ProgressRow
var updatedAt time.Time
if err := rows.Scan(
&p.ContentID,
&p.CurrentSeconds,
&p.DurationSeconds,
&p.IsFinished,
&p.ProgressPct,
&updatedAt,
); err != nil {
return nil, fmt.Errorf("abs_progress_store: scan progress row: %w", err)
}
p.UserID = userID
p.ProfileID = profileID
p.UpdatedAt = updatedAt
result = append(result, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_progress_store: iterate progress rows: %w", err)
}
return result, nil
}
// UpsertProgress inserts or updates a user_watch_progress row. Conflict
// updates merge monotonically so concurrent writes cannot un-finish an item or
// rewind progress with a stale position.
func (s *ABSProgressStore) UpsertProgress(ctx context.Context, row abs.ProgressRow) error {
uid, err := strconv.Atoi(row.UserID)
if err != nil {
return fmt.Errorf("abs_progress_store: invalid user_id %q: %w", row.UserID, err)
}
updatedAt := row.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds),
duration_seconds = GREATEST(user_watch_progress.duration_seconds, EXCLUDED.duration_seconds),
completed = user_watch_progress.completed OR EXCLUDED.completed,
updated_at = GREATEST(user_watch_progress.updated_at, EXCLUDED.updated_at)`,
uid, row.ProfileID, row.ContentID,
row.CurrentSeconds, row.DurationSeconds, row.IsFinished, updatedAt,
)
if err != nil {
return fmt.Errorf("abs_progress_store: upsert progress: %w", err)
}
return nil
}
// UpdateProgressPosition updates only the position_seconds column for an
// existing row. If no row exists this is a no-op (the session-sync path
// that calls this only needs to move the cursor, not create a progress row
// for the first time; that's done when the user explicitly sets progress).
func (s *ABSProgressStore) UpdateProgressPosition(ctx context.Context, userID, profileID, contentID string, positionSeconds float64) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_progress_store: invalid user_id %q: %w", userID, err)
}
_, err = s.Pool.Exec(ctx, `
UPDATE user_watch_progress
SET position_seconds = $4,
updated_at = now()
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
uid, profileID, contentID, positionSeconds,
)
if err != nil {
return fmt.Errorf("abs_progress_store: update progress position: %w", err)
}
return nil
}
// DeleteProgress removes the progress row entirely. Idempotent on missing-row.
// Used by the ABS "Reset Progress" / clear-progress affordance.
func (s *ABSProgressStore) DeleteProgress(ctx context.Context, userID, profileID, contentID string) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_progress_store: invalid user id %q: %w", userID, err)
}
if _, err := s.Pool.Exec(ctx, `
DELETE FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
uid, profileID, contentID,
); err != nil {
return fmt.Errorf("abs_progress_store: delete progress: %w", err)
}
return nil
}
// SetHideFromContinue sets the hide_from_continue flag for the given
// progress row. Idempotent on missing-row.
func (s *ABSProgressStore) SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_progress_store: invalid user id %q: %w", userID, err)
}
if _, err := s.Pool.Exec(ctx, `
UPDATE user_watch_progress
SET hide_from_continue = $4
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
uid, profileID, contentID, hide,
); err != nil {
return fmt.Errorf("abs_progress_store: set hide_from_continue: %w", err)
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
type ABSRSSFeedStore struct {
Pool *pgxpool.Pool
}
var _ abs.RSSFeedStore = (*ABSRSSFeedStore)(nil)
func (s *ABSRSSFeedStore) ListUserFeeds(ctx context.Context, userID, profileID string) ([]abs.RSSFeed, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at
FROM abs_rss_feeds
WHERE user_id = $1
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
AND closed_at IS NULL
ORDER BY created_at DESC`,
uid, profileArg(profileID),
)
if err != nil {
return nil, fmt.Errorf("abs_rss_feed_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.RSSFeed, 0)
for rows.Next() {
var f abs.RSSFeed
var uidScan int
var profileScan *string
if err := rows.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil {
return nil, fmt.Errorf("abs_rss_feed_store: list scan: %w", err)
}
f.UserID = strconv.Itoa(uidScan)
if profileScan != nil {
f.ProfileID = *profileScan
}
out = append(out, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_rss_feed_store: list rows: %w", err)
}
return out, nil
}
func (s *ABSRSSFeedStore) GetFeed(ctx context.Context, id string) (abs.RSSFeed, error) {
return s.getFeedRow(ctx, "id = $1", id)
}
func (s *ABSRSSFeedStore) GetFeedBySlug(ctx context.Context, slug string) (abs.RSSFeed, error) {
return s.getFeedRow(ctx, "slug = $1 AND closed_at IS NULL", slug)
}
func (s *ABSRSSFeedStore) getFeedRow(ctx context.Context, where string, arg string) (abs.RSSFeed, error) {
var f abs.RSSFeed
var uidScan int
var profileScan *string
row := s.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at
FROM abs_rss_feeds WHERE `+where, arg)
if err := row.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.RSSFeed{}, abs.ErrNotFound
}
return abs.RSSFeed{}, fmt.Errorf("abs_rss_feed_store: get: %w", err)
}
f.UserID = strconv.Itoa(uidScan)
if profileScan != nil {
f.ProfileID = *profileScan
}
return f, nil
}
func (s *ABSRSSFeedStore) CreateFeed(ctx context.Context, f abs.RSSFeed) error {
uid, err := strconv.Atoi(f.UserID)
if err != nil {
return fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", f.UserID, err)
}
if _, err := s.Pool.Exec(ctx, `
INSERT INTO abs_rss_feeds (id, user_id, profile_id, library_item_id, slug, minified)
VALUES ($1, $2, $3::uuid, $4, $5, $6)`,
f.ID, uid, profileArg(f.ProfileID), f.LibraryItemID, f.Slug, f.Minified,
); err != nil {
return fmt.Errorf("abs_rss_feed_store: create: %w", err)
}
return nil
}
func (s *ABSRSSFeedStore) CloseFeed(ctx context.Context, id string) error {
if _, err := s.Pool.Exec(ctx, `UPDATE abs_rss_feeds SET closed_at = now() WHERE id = $1 AND closed_at IS NULL`, id); err != nil {
return fmt.Errorf("abs_rss_feed_store: close: %w", err)
}
return nil
}
+158
View File
@@ -0,0 +1,158 @@
package audiobooks
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSSessionStore implements abs.TokenStore on the abs_sessions table
// (migration 147). Each JTI is stored as a SHA-256 `token_hash`; revocation
// sets `revoked_at`. The table stores integer user_id, so we parse the
// string UserID from ABSToken back to int.
type ABSSessionStore struct {
Pool *pgxpool.Pool
}
// InsertToken inserts a newly minted JTI into abs_sessions.
// Duplicate tokens are silently ignored (ON CONFLICT DO NOTHING) so
// concurrent requests using the same JTI are idempotent.
func (s *ABSSessionStore) InsertToken(ctx context.Context, tok abs.ABSToken) error {
uid, err := strconv.Atoi(tok.UserID)
if err != nil {
return fmt.Errorf("abs_session_store: invalid user id %q: %w", tok.UserID, err)
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO abs_sessions
(user_id, profile_id, token_hash, token_type, expires_at, device_id, device_name, client_name, client_version, last_seen_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
ON CONFLICT (token_hash) DO NOTHING`,
uid,
tok.ProfileID,
absTokenHash(tok.JTI),
tok.Type,
tok.ExpiresAt,
tok.JTI, // device_id: use the JTI as a stable device key
"", // device_name: unknown at login time
"abs-compat",
"",
)
if err != nil {
return fmt.Errorf("abs_session_store: insert token: %w", err)
}
return nil
}
// GetTokenByJTI looks up an abs_sessions row by the SHA-256 hash of its JTI.
// Returns abs.ErrNotFound when the row doesn't exist.
func (s *ABSSessionStore) GetTokenByJTI(ctx context.Context, jti string) (abs.ABSToken, error) {
var (
uid int
profileID string
expiresAt *time.Time
tok abs.ABSToken
)
row := s.Pool.QueryRow(ctx, `
SELECT user_id, profile_id, token_type, expires_at, revoked_at
FROM abs_sessions
WHERE token_hash = $1`, absTokenHash(jti))
err := row.Scan(&uid, &profileID, &tok.Type, &expiresAt, &tok.RevokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return abs.ABSToken{}, abs.ErrNotFound
}
if err != nil {
return abs.ABSToken{}, fmt.Errorf("abs_session_store: get token: %w", err)
}
tok.JTI = jti
tok.ID = jti
tok.UserID = strconv.Itoa(uid)
tok.ProfileID = profileID
if expiresAt != nil {
tok.ExpiresAt = *expiresAt
}
return tok, nil
}
// RevokeTokenByJTI sets revoked_at to now() for the given JTI.
// Idempotent: revoking an already-revoked token is a no-op.
func (s *ABSSessionStore) RevokeTokenByJTI(ctx context.Context, jti string) error {
_, err := s.Pool.Exec(ctx, `
UPDATE abs_sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`, absTokenHash(jti))
if err != nil {
return fmt.Errorf("abs_session_store: revoke token: %w", err)
}
return nil
}
func (s *ABSSessionStore) RevokeTokenIfActive(ctx context.Context, jti string) (abs.ABSToken, error) {
var (
uid int
profileID string
expiresAt *time.Time
tok abs.ABSToken
)
row := s.Pool.QueryRow(ctx, `
UPDATE abs_sessions
SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
RETURNING user_id, profile_id, token_type, expires_at, revoked_at`, absTokenHash(jti))
err := row.Scan(&uid, &profileID, &tok.Type, &expiresAt, &tok.RevokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return abs.ABSToken{}, abs.ErrNotFound
}
if err != nil {
return abs.ABSToken{}, fmt.Errorf("abs_session_store: revoke active token: %w", err)
}
tok.JTI = jti
tok.ID = jti
tok.UserID = strconv.Itoa(uid)
tok.ProfileID = profileID
if expiresAt != nil {
tok.ExpiresAt = *expiresAt
}
return tok, nil
}
func (s *ABSSessionStore) RevokeTokensForPrincipal(ctx context.Context, userID, profileID string) error {
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("abs_session_store: invalid user id %q: %w", userID, err)
}
_, err = s.Pool.Exec(ctx, `
UPDATE abs_sessions
SET revoked_at = now()
WHERE user_id = $1 AND profile_id = $2 AND revoked_at IS NULL`,
uid, profileID,
)
if err != nil {
return fmt.Errorf("abs_session_store: revoke principal tokens: %w", err)
}
return nil
}
// TouchToken bumps last_seen_at for active-session bookkeeping.
// Errors are logged by the caller; we never gate a valid request on this.
func (s *ABSSessionStore) TouchToken(ctx context.Context, jti string) error {
_, err := s.Pool.Exec(ctx, `
UPDATE abs_sessions SET last_seen_at = now()
WHERE token_hash = $1`, absTokenHash(jti))
if err != nil {
return fmt.Errorf("abs_session_store: touch token: %w", err)
}
return nil
}
func absTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,136 @@
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// ABSSmartCollectionStore implements abs.SmartCollectionStore against the
// canonical user_personal_collections table (migration 156). ABS smart
// collections live in user_personal_collections with collection_type =
// 'smart'; the rule DSL is stored in the query_definition jsonb column
// (formerly abs_smart_collections.query_def). Smart collections have no
// membership rows — items are materialised at request time by the
// smartcoll engine, so user_personal_collection_items is unused for
// collection_type = 'smart'.
//
// abs.SmartCollection.IsPublic maps to user_personal_collections.is_shared.
// profile_id is a text column (NOT NULL DEFAULT '') in the canonical
// schema, so the empty string stands in for "primary profile".
//
// abs.SmartCollection.Color and abs.SmartCollection.IsPinned have no
// canonical columns (deferred per spec §6); reads always return the zero
// value and writes ignore them.
type ABSSmartCollectionStore struct {
Pool *pgxpool.Pool
}
var _ abs.SmartCollectionStore = (*ABSSmartCollectionStore)(nil)
// absCollectionTypeSmart is the discriminator value for ABS smart
// collections in the canonical user_personal_collections table.
const absCollectionTypeSmart = "smart"
func (s *ABSSmartCollectionStore) ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]abs.SmartCollection, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_smart_collection_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, query_definition, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = $3
AND user_id = $1
AND profile_id = $2
ORDER BY created_at DESC`,
uid, profileID, absCollectionTypeSmart,
)
if err != nil {
return nil, fmt.Errorf("abs_smart_collection_store: list: %w", err)
}
defer rows.Close()
out := make([]abs.SmartCollection, 0)
for rows.Next() {
var c abs.SmartCollection
var uidScan int
if err := rows.Scan(&c.ID, &uidScan, &c.ProfileID, &c.Name, &c.Description, &c.IsPublic, &c.QueryDef, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("abs_smart_collection_store: list scan: %w", err)
}
c.UserID = strconv.Itoa(uidScan)
// Color / IsPinned have no canonical columns — always zero.
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_smart_collection_store: list rows: %w", err)
}
return out, nil
}
func (s *ABSSmartCollectionStore) GetSmartCollection(ctx context.Context, id string) (abs.SmartCollection, error) {
var c abs.SmartCollection
var uidScan int
row := s.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, name, description, is_shared, query_definition, created_at, updated_at
FROM user_personal_collections
WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypeSmart,
)
if err := row.Scan(&c.ID, &uidScan, &c.ProfileID, &c.Name, &c.Description, &c.IsPublic, &c.QueryDef, &c.CreatedAt, &c.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.SmartCollection{}, abs.ErrNotFound
}
return abs.SmartCollection{}, fmt.Errorf("abs_smart_collection_store: get: %w", err)
}
c.UserID = strconv.Itoa(uidScan)
// Color / IsPinned have no canonical columns — always zero.
return c, nil
}
func (s *ABSSmartCollectionStore) CreateSmartCollection(ctx context.Context, c abs.SmartCollection) error {
uid, err := strconv.Atoi(c.UserID)
if err != nil {
return fmt.Errorf("abs_smart_collection_store: invalid user id %q: %w", c.UserID, err)
}
// Color / IsPinned are intentionally not persisted (no canonical columns).
if _, err := s.Pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, creator_profile_id, name, description,
collection_type, is_shared, query_definition, created_at, updated_at)
VALUES ($1, $2, $3, $3, $4, $5, $6, $7, $8::jsonb, now(), now())`,
c.ID, uid, c.ProfileID, c.Name, c.Description, absCollectionTypeSmart, c.IsPublic, c.QueryDef,
); err != nil {
return fmt.Errorf("abs_smart_collection_store: create: %w", err)
}
return nil
}
func (s *ABSSmartCollectionStore) UpdateSmartCollection(ctx context.Context, c abs.SmartCollection) error {
// Color / IsPinned are intentionally not persisted (no canonical columns).
if _, err := s.Pool.Exec(ctx, `
UPDATE user_personal_collections
SET name = $2, description = $3, is_shared = $4, query_definition = $5::jsonb, updated_at = now()
WHERE id = $1 AND collection_type = $6`,
c.ID, c.Name, c.Description, c.IsPublic, c.QueryDef, absCollectionTypeSmart,
); err != nil {
return fmt.Errorf("abs_smart_collection_store: update: %w", err)
}
return nil
}
func (s *ABSSmartCollectionStore) DeleteSmartCollection(ctx context.Context, id string) error {
// Smart collections have no membership rows; no tx needed.
if _, err := s.Pool.Exec(ctx,
`DELETE FROM user_personal_collections WHERE id = $1 AND collection_type = $2`,
id, absCollectionTypeSmart,
); err != nil {
return fmt.Errorf("abs_smart_collection_store: delete: %w", err)
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
// Package abssocket exposes a Socket.io-compatible realtime endpoint for
// the official Audiobookshelf clients, mounted at /abs/socket.io/* on silo's
// main HTTP listener.
//
// Authentication mirrors real ABS exactly: a Socket.io connection opens
// unauthenticated, then the client emits an "auth" event whose payload is
// the access JWT minted by /abs/api/login. We validate the JWT against the
// signing secret supplied via SecretFn (same secret /abs/api/me and friends
// already use), then join the connection to a user-scoped Socket.io room.
// Events published via Publish(userID, ...) reach every client currently
// connected with that user's token on this process.
//
// Scope: single-process hub. A Redis adapter can be injected via
// Options.Adapter for multi-replica deployments; without it, fan-out is
// in-memory only.
package abssocket
import (
"context"
"net/http"
"strings"
"sync"
"time"
"github.com/zishang520/socket.io/v2/socket"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
)
// Logger is the narrow logging surface this package needs.
type Logger interface {
Debug(msg string, args ...any)
Warn(msg string, args ...any)
}
type noopLogger struct{}
func (noopLogger) Debug(string, ...any) {}
func (noopLogger) Warn(string, ...any) {}
// SecretFn returns the current ABS JWT signing secret. Called on every
// inbound "auth" event so an admin secret-rotate takes effect for new
// connections without a server restart.
type SecretFn func() []byte
// TokenValidator checks whether a JTI is still valid (non-revoked). It is
// optional — if nil, only JWT signature and expiry gate connections.
type TokenValidator func(ctx context.Context, jti string) (revoked bool, err error)
// Server is the Socket.io realtime server. Construct one per process and
// reuse across reconfigures.
type Server struct {
io *socket.Server
secretFn SecretFn
tokenValidator TokenValidator
logger Logger
mu sync.Mutex
connCount int // diagnostics only
}
// Options bundles optional knobs for New.
type Options struct {
// Adapter swaps the in-memory Socket.io adapter for an external one,
// e.g. a Redis adapter for multi-replica deployments. nil keeps the
// built-in in-memory adapter.
Adapter socket.AdapterConstructor
}
// New builds a Server. secretFn is required; tokenValidator and logger are
// optional (nil tokenValidator skips revocation check; nil logger is a no-op).
// opts may be nil for default single-replica behaviour.
func New(secretFn SecretFn, tokenValidator TokenValidator, logger Logger, opts *Options) *Server {
if logger == nil {
logger = noopLogger{}
}
srvOpts := socket.DefaultServerOptions()
if opts != nil && opts.Adapter != nil {
srvOpts.SetAdapter(opts.Adapter)
}
io := socket.NewServer(nil, srvOpts)
s := &Server{
io: io,
secretFn: secretFn,
tokenValidator: tokenValidator,
logger: logger,
}
io.On("connection", func(args ...any) {
if len(args) == 0 {
return
}
client, ok := args[0].(*socket.Socket)
if !ok {
return
}
s.onConnection(client)
})
return s
}
func (s *Server) onConnection(client *socket.Socket) {
s.mu.Lock()
s.connCount++
s.mu.Unlock()
s.logger.Debug("abssocket: connection opened", "sid", client.Id())
// ABS clients emit "auth" once with the access token as the payload.
// Until that succeeds the socket sits in the unauthenticated default
// namespace and receives no scoped events.
//
// Event names match the upstream ABS server (SocketAuthority.js):
// successful auth fires "init" with a user-state payload; failed auth
// fires "auth_failed" with {message}. The official ABS mobile + web
// clients listen for these specific names.
client.On("auth", func(args ...any) {
token := pickToken(args)
if token == "" {
s.logger.Warn("abssocket: auth without token", "sid", client.Id())
_ = client.Emit("auth_failed", map[string]any{"message": "missing token"})
client.Disconnect(true)
return
}
secret := s.secretFn()
if len(secret) == 0 {
s.logger.Warn("abssocket: server not ready (no jwt secret)", "sid", client.Id())
_ = client.Emit("auth_failed", map[string]any{"message": "server not ready"})
client.Disconnect(true)
return
}
claims, err := abs.ParseToken(secret, token)
if err != nil || claims.Type != "access" {
s.logger.Warn("abssocket: auth rejected", "sid", client.Id(), "err", errString(err))
_ = client.Emit("auth_failed", map[string]any{"message": "invalid token"})
client.Disconnect(true)
return
}
if s.tokenValidator != nil {
revoked, err := s.tokenValidator(context.Background(), claims.JTI)
if err != nil || revoked {
s.logger.Warn("abssocket: token revoked", "sid", client.Id(), "jti", claims.JTI)
_ = client.Emit("auth_failed", map[string]any{"message": "token revoked"})
client.Disconnect(true)
return
}
}
// Bind the socket to a user-scoped room so Publish(userID, ...) can
// fan a single in-process emit across every device on that account.
client.Join(userRoom(claims.UserID))
s.logger.Debug("abssocket: auth ok", "sid", client.Id(), "user_id", claims.UserID)
_ = client.Emit("init", map[string]any{
"userId": claims.UserID,
"connectedAt": time.Now().UnixMilli(),
})
})
client.On("disconnect", func(...any) {
s.mu.Lock()
if s.connCount > 0 {
s.connCount--
}
s.mu.Unlock()
s.logger.Debug("abssocket: connection closed", "sid", client.Id())
})
}
// Handler returns the http.Handler to mount at /socket.io/*.
func (s *Server) Handler() http.Handler {
return s.io.ServeHandler(nil)
}
// Publish emits the given event to every socket currently joined to the
// user's room. Non-blocking; a publish to a userID with zero connected
// sockets is a no-op. Satisfies abs.EventPublisher.
func (s *Server) Publish(userID, event string, payload any) {
if userID == "" {
return
}
s.io.To(userRoom(userID)).Emit(event, payload)
}
// Broadcast emits to every authenticated socket regardless of user. Use
// for global events like library_item_added that aren't user-scoped.
// Satisfies abs.EventPublisher.
func (s *Server) Broadcast(event string, payload any) {
s.io.Emit(event, payload)
}
// ConnectionCount returns the current connection count for diagnostics.
func (s *Server) ConnectionCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.connCount
}
// Close shuts the Socket.io server down. Idempotent.
func (s *Server) Close() {
s.io.Close(nil)
}
func userRoom(userID string) socket.Room {
return socket.Room("user:" + userID)
}
// pickToken extracts the bearer JWT from the variadic "auth" payload.
// Real ABS clients send a single string; our SPA may send {token: "..."}.
func pickToken(args []any) string {
if len(args) == 0 {
return ""
}
switch v := args[0].(type) {
case string:
return strings.TrimSpace(v)
case map[string]any:
if t, ok := v["token"].(string); ok {
return strings.TrimSpace(t)
}
}
return ""
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
@@ -0,0 +1,98 @@
package abssocket_test
import (
"net/http/httptest"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/audiobooks/abssocket"
)
// recordingLogger captures Warn/Debug calls so we can assert on auth-reject
// paths without depending on hclog wiring.
type recordingLogger struct {
mu sync.Mutex
logs []string
}
func (r *recordingLogger) Debug(msg string, _ ...any) {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, "debug:"+msg)
}
func (r *recordingLogger) Warn(msg string, _ ...any) {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, "warn:"+msg)
}
// TestNew_BuildsAndExposesHandler is the bare-minimum smoke: New returns a
// Server, Handler is non-nil, Close is a no-op when never used.
func TestNew_BuildsAndExposesHandler(t *testing.T) {
s := abssocket.New(
func() []byte { return nil },
nil, // nil tokenValidator → skip revocation check
nil, // nil logger → noop
nil, // default in-memory adapter
)
if s == nil {
t.Fatal("New returned nil")
}
if s.Handler() == nil {
t.Fatal("Handler() returned nil")
}
s.Close()
}
// TestHandler_ServesSocketIOOpenHandshake confirms the underlying engine
// returns the Engine.io handshake document on the initial polling GET. The
// payload starts with the Engine.io OPEN packet identifier ("0") followed
// by a JSON envelope carrying {sid, upgrades, pingInterval, pingTimeout}.
func TestHandler_ServesSocketIOOpenHandshake(t *testing.T) {
s := abssocket.New(
func() []byte { return []byte("a-32-byte-secret-for-handshakes!") },
nil,
&recordingLogger{},
nil,
)
defer s.Close()
req := httptest.NewRequest("GET", "/socket.io/?EIO=4&transport=polling", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("handshake status = %d, want 200; body=%q", w.Code, w.Body.String())
}
body := w.Body.String()
if len(body) == 0 || body[0] != '0' {
// Engine.io v4 prefixes the open packet with literal "0". Any
// other shape means we're not actually serving Socket.io.
t.Fatalf("body must start with Engine.io OPEN ('0'); got %q", body)
}
}
// TestPublish_NoConnections_IsNoOp ensures the publish path doesn't panic
// when no sockets are connected to the user's room.
func TestPublish_NoConnections_IsNoOp(t *testing.T) {
s := abssocket.New(
func() []byte { return nil },
nil,
nil,
nil,
)
defer s.Close()
// Should not panic, should not block.
s.Publish("u-no-such-user", "user_item_progress_updated", map[string]any{"x": 1})
s.Publish("", "noop", nil) // empty user id is an explicit no-op
}
// TestEventPublisherInterfaceSatisfied is a compile-time check (executed at
// test time only). It guarantees that abssocket.Server stays usable wherever
// abs.EventPublisher is expected.
func TestEventPublisherInterfaceSatisfied(t *testing.T) {
var _ abs.EventPublisher = (*abssocket.Server)(nil)
}

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