Transient DB errors from the skip-check fell through silently to the
full reconcile path. The fall-through is still correct, but operators
need visibility when it happens - a 240K-folder rescan that quietly
loses the fast path is a 12h elapsed_sec, not 12s.
Bypasses ffprobe + DB writes for folders whose on-disk audio files
match existing media_files rows by size and mtime. Stable libraries
now rescan in minutes instead of hours.
Introduces audiobookDiskFile + audiobookFolderUnchanged to detect when a
folder's on-disk audio files match existing media_files rows by path,
size, and mtime. Sets up the upcoming scan-loop skip path that bypasses
ffprobe + DB writes for stable folders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires up the endpoints AudioBooth + the official audiobookshelf-app
expect but Silo's ABS-compat layer was missing, plus fixes the recently
shipped playlists/collections/series surfaces that were crashing the
mobile clients.
Browse surfaces (crash fixes):
- Hydrate playlist items with full LibraryItem (mediaType, media,
cover) so PlaylistCover + LazyBookCard stop throwing on undefined
fields.
- Add GET /libraries/{id}/collections paged envelope; hydrate
collection books with full LibraryItem so the cover stack renders.
- Add GET /libraries/{id}/playlists total field so LazyBookshelf
paginates correctly.
- Return real numUserPlaylists count from /libraries/{id} so the
bottom-nav Playlists tab actually appears.
- Extend ListLibrarySeries with up to 4 book previews per series
(window-ranked SQL) so LazySeriesCard renders cover stacks
instead of name-only placeholders.
- Auto-delete a playlist when its last item is removed; fire
playlist_removed so the client routes the user back to the list.
New mobile-blocking endpoints:
- PATCH alias on /me/progress/{id} (AudioBooth's progress writes).
- DELETE /me/progress/{id} backed by new DeleteProgress store method.
- GET /me/stats/year/{year} — year-in-review synthesised from
AggregateStats.
- GET /ping, /healthcheck, /init, /auth-settings — unauthenticated
server discovery on every prefix.
Ebook surface (forward-compat stubs):
- LibraryItemMedia.EbookFile omitempty field + EbookFile type.
- progressBody accepts ebookProgress + ebookLocation (silently
ignored until ebook scanner lands).
- GET /items/{id}/ebook/{fileid} 404 stub, PATCH .../status accept.
- GET /me/ereader-devices empty list, POST
/emails/send-ebook-to-device 503 with clear "not configured".
Podcast stubs (audiobook-only catalog v1):
- POST /podcasts/feed, POST /items/{id}/play/{episodeId},
PATCH /me/progress/{id}/{episodeId}, GET
/libraries/{id}/recent-episodes, GET /search/podcast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ABS mobile create-playlist modal loads existing playlists
BEFORE opening the form, hitting:
GET /api/libraries/{libraryId}/playlists
and reading `data.results` (NOT `data.playlists`). It then iterates
each playlist's `items[]` to render "already in this playlist"
badges. silo's existing `/api/playlists` route emitted the wrong
envelope (`{playlists: [...]}`) and was at the wrong path; the
modal silently treated the response as empty, the user saw an
empty existing-playlist list, and depending on the device-side
UX (no "Create New" CTA when results was undefined) the create
action looked dead.
Add handleListLibraryPlaylists at `/libraries/{libraryId}/playlists`,
emit `{results: [Playlist full-shape]}` so playlist.items is
populated for the membership check. libraryId param accepted but
ignored — silo scopes playlists per (user, profile) globally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ABS mobile create-playlist modal builds the playlist + initial
members in one round-trip:
POST /api/playlists { items: [...], libraryId, name }
(see audiobookshelf-app components/modals/playlists/AddCreateModal.vue
submitCreatePlaylist). silo's handler only accepted
{ name, description?, cover_item?, isPublic? } — items were
silently dropped, the playlist was created empty, and the client's
.then(data) handler closed the modal but the user never saw their
selected books in the playlist (and the device-side socket event
showed an empty list).
Extend playlistBody with Items + LibraryID. After creating the
playlist row, iterate Items and call AddPlaylistItem for each
(audiobook items validated via MediaStore; episode items skip
validation per the existing accept-and-echo policy). Refresh
the playlist before returning so the client sees the populated
items[] in the response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit of the ABS Android Kotlin data classes against silo's emitted
JSON found six non-nullable LibraryItem fields silo wasn't emitting
at all (ino, path, relPath, mtimeMs, ctimeMs, birthtimeMs) plus a
non-nullable BookMetadata.explicit. jackson-module-kotlin's behaviour
around missing required parameters varies by config — some flows are
lenient enough that the library list loads with the omissions, but
the strict download path (apiHandler.getLibraryItemWithProgress)
hits a MissingKotlinParameterException and silently resolves cb(null)
which kills the AbsDownloader without a user-visible toast.
Emit safe defaults: ino = ContentID (stable item-level identifier
matching the real-ABS shape), path/relPath = "", mtime/ctime/
birthtimeMs = AddedAt, explicit = false. Costs almost nothing on the
wire and forecloses future Jackson-strictness surprises.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ABS Android client's Kotlin Book model declares
var tags: List<String> // non-nullable
and Jackson throws MissingKotlinParameterException when the field is
absent from /api/items/{id} responses. ApiHandler.getLibraryItem
WithProgress catches the exception and resolves cb(null), at which
point AbsDownloader silently aborts — the user sees no error, the
download just never starts.
Add Tags to LibraryItemMedia and initialise it to []string{} at
construction. Verified end-to-end: download URL the Android client
builds (/api/items/{id}/file/{ino}/download) returns HTTP 206 with
audio/mpeg bytes; the parent /api/items/{id} response now carries
"tags": [] so Jackson deserialises cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs the ABS Android/iOS client surfaces against silo:
1. WebSocket upgrade returns 400. The access-log middleware wraps
the ResponseWriter in a statusRecorder that doesn't implement
http.Hijacker, so socket.io can't take ownership of the raw
connection. Engine.io rejects the upgrade with
{"code":3,"message":"Bad request"}. Fix: add Hijack + Flush
passthroughs on statusRecorder.
2. ABS v2.26+ clients prompt "Authentication has been improved for
security in server v2.26.0. All users are required to re-login."
on every layout mount. The client check is:
if (serverConfig.token === user.token || user.isOldToken)
silo's /authorize was echoing the inbound bearer back as
user.token — equality fires, re-login forced. Fix: mint a fresh
access JWT in /authorize, persist its JTI, and return it as
user.token / accessToken. Old JTI stays valid for its natural
TTL (multi-device share-on-login). Refresh token contract
unchanged.
Verified: curl /authorize returns user.token != original bearer;
WS upgrade probe now returns HTTP 101.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires RSSFeedStore into BuildABSHandler. Mounts the ten new
authenticated routes (stats x3 + author x1 + series x1 + continue x2
+ feeds-auth x3) under both /abs/api and /api inside bearerAuth.
Mounts the three public RSS routes (.xml + slug + /file/{ino}) in
the unauth public block. handleAuthorImage wired to CoverResolver.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /feed/{slug}.xml (and /feed/{slug}) generates a minimal RSS 2.0
document with one <item> per media_file. GET /feed/{slug}/file/{ino}
streams the underlying file when ino belongs to the feed's
library_item_id. Both routes are unauthenticated — slug is the
capability token; closed feeds 404.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/feeds/item/{itemId}/open (auto-gen or custom slug),
GET /api/feeds (owner+profile scope, only open feeds),
POST /api/feeds/{id}/close (idempotent, owner-gated).
Slug validation via ^[a-z0-9-]{4,64}$. 409 on collision via pgx
unique-violation substring detection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two GET endpoints (remove-from / readd-to-continue-listening) backed
by ProgressStore.SetHideFromContinue. ListContinueListening SQL gains
a hide_from_continue = false filter so hidden items drop off the shelf
immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /authors/{id} (people.id, kind=7 join over item_people) and
GET /series/{id} (case-insensitive series_name match, ordered by
series_index NULLS LAST). Both return entity + embedded books[].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 adds hide_from_continue to user_watch_progress (backs the
ABS remove/readd-to-continue-listening endpoints). 155 creates
abs_rss_feeds for the upcoming RSS surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combined spec for the four remaining Phase 1 surfaces. Migrations 154
(hide_from_continue column on user_watch_progress) and 155
(abs_rss_feeds). RSS scoped to item-feed minimum; series/collection
feed variants and RSS cover/track endpoints deferred to Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pgx-backed store + service wiring + six routes registered under both
/abs/api and /api prefixes inside the existing bearerAuth group.
JSONB column written via $9::jsonb cast for query_def.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Items handler evaluates the stored query_def against the audiobook
catalog. Per-user state hydrated in 2 batched calls (progress list +
bookmark counts) when caller is the owner; non-owner viewing public
sees personalized rules silently dropped. Results paginated post-eval.
siloItemToSmartcollItem adapter maps silo's MediaItem onto the
audiobook-domain Item shape; author/narrator/series/publisher/
duration_seconds left as zero-values for v1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four CRUD handlers + tests. Anti-enumeration 404 on non-owner
private. List envelope is {"items": [...]}. PATCH re-validates
query_def when present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First handler of the smart collections surface. query_def is
normalized + validated (with allowPersonalized=true) before
marshalling to JSONB bytes for storage. Adds memSmartCollectionStore
harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines the storage contract and wire-shape serialiser. queryDef is
emitted as a nested JSON object on the wire (decoded from the JSONB
bytes once at serialisation time).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-memory rule evaluator over Candidate{Item, IsFinished, ProgressPct,
CurrentSeconds, LastPlayedAt, BookmarkCount, PlayCount}. Covers all
15 fields + 7 operators + 9 sort keys including deterministic
seeded random. Personalized rules drop to false (silent) when
opts.AllowPersonalized is false. Decoupled from silo's catalog
model via the local Item struct — handler adapts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 153 backs the upcoming smart-collections surface.
BookmarkStore.CountByUser returns per-item counts in one SQL pass —
used by the smart-collection items evaluator to hydrate the
bookmark_count personalized rule without N+1 queries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 TDD-ordered tasks. DSL package ported from continuum with audiobook
catalog. CRUD handlers + items eval with batched per-user state
hydration. Author/narrator/series/duration hydration deferred to a
follow-up — rules referencing those fields evaluate false in v1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final review flagged two small gaps in the delete tests:
- TestCollection_Delete_Owner_204 now seeds a book and asserts the
items table is empty after delete (proves FK CASCADE works).
- TestPlaylist_Delete_Owner_FiresRemovedEvent now also asserts the
post-delete GET returns 404 (symmetry with the collection test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the pgx-backed PlaylistStore impl, wires it into BuildABSHandler
when a Pool is present, and registers all ten playlists routes under
both /abs/api and /api prefixes inside the existing bearerAuth group.
AddPlaylistItem computes position = MAX+1 inside the INSERT (one
round-trip, no read-before-write race); both add and remove run in
transactions so the parent's updated_at bump is atomic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /playlists/{id}/batch/add and /batch/remove accept arrays of
{libraryItemId, episodeId?} tuples. Per-item failures are tolerated
silently (matching continuum); only a whole-body decode error
surfaces as 400. One playlist_updated event fires for the whole
batch regardless of per-item outcomes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two route variants share the same body via removePlaylistItemImpl:
the bare libraryItemId form removes the item with empty episode_id;
the libraryItemId+episodeId form removes only that episode-keyed
entry, leaving other entries with the same libraryItemId intact.
Idempotent; fires playlist_updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Append a new (libraryItemId, episodeId) tuple to the end of the
playlist (positions start at 1 and increment). Audiobook items
validated against MediaStore (404 on unknown); episode items
accept-and-echo per spec §7.1 (podcast hydration is a future
sub-project). Idempotent on the tuple. Fires playlist_updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner-gated mutation with partial-body PATCH semantics. Non-owner gets
404 (anti-enumeration). Both handlers fire realtime events
(playlist_updated, playlist_removed) — clients re-render from the
response (the event payloads carry only the id).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
List wraps the result in {"playlists": [...]} and emits list-shape
(no items[]). Detail handler returns full-shape for owner or for any
caller when isPublic=true; otherwise 404 matching the bookmarks
anti-enumeration pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First handler of the playlists surface. Body {name, description?,
cover_item?, isPublic?} returns the created playlist in full-shape
(empty items[]). Fires playlist_added realtime event. Adds the
in-memory test harness (memPlaylistStore) parallel to
memCollectionStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines the storage contract and wire-shape serialiser the playlists
handlers will consume. Envelope test asserts the eight (or nine with
coverPath) top-level keys including description always round-tripped
correctly and coverPath omitted when empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrations 151 + 152 back the upcoming ABS playlist endpoints.
Schema rationale documented in
docs/superpowers/specs/2026-05-26-abs-collections-playlists-design.md
§5.3, §5.4, §5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace string-compare on "no rows in result set" with the canonical
errors.Is(err, pgx.ErrNoRows) — matches every other store in
internal/audiobooks/ (abs_session_store, abs_playback_session_store,
abs_progress_store) and is robust against pgx error-message changes.
Also wrap rows.Err() returns with the contextual fmt.Errorf prefix
that the bookmark store uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the pgx-backed CollectionStore impl (parallel to
abs_bookmark_store.go), wires it into BuildABSHandler when a Pool is
present, and registers the seven collections routes under both
/abs/api and /api prefixes inside the existing bearerAuth group.
AddCollectionItem/RemoveCollectionItem run in a transaction so the
parent's updated_at bump is atomic with the item mutation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /collections/{id}/book/{bookId} validates the item against
MediaStore (404 on unknown) and is idempotent on the store side. The
DELETE variant is unconditional idempotent (returns the current
membership state regardless of whether the row existed). Both 404
when non-owner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner-gated mutation with partial-body PATCH semantics (only fields
present in the body are updated). Non-owner attempts return 404
matching the bookmarks anti-enumeration pattern. DELETE cascades to
abs_collection_items via FK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner sees their own collection in full-shape (with books[]).
Non-owner sees it only when isPublic=true; otherwise 404 with the same
body as a genuine not-found (anti-enumeration pattern from the
bookmarks sub-project).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the result in {"collections": [...]} matching continuum/real-ABS
clients. Owner-scope only (other users' collections never leaked).
Profile-scoped (collections under a different profile excluded).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First handler of the collections surface. Body {name, description?,
isPublic?} returns the created collection in full-shape (empty
books[]). Backed by the new CollectionStore dependency (nil-safe:
handler returns 503 when unwired). Adds the in-memory test harness
(memCollectionStore + dispatchABSWithParams) that the rest of the
collections suite will reuse.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines the storage contract and wire-shape serialiser the collections
handlers will consume. Envelope test asserts the seven required keys
including description (which the continuum reference always emits as
empty regardless of stored value — this round-trips it correctly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrations 149 + 150 back the upcoming ABS collection endpoints.
Schema rationale documented in
docs/superpowers/specs/2026-05-26-abs-collections-playlists-design.md
§5.1, §5.2, §5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>