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>
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>
- Drop dead pgx.ErrNoRows guard in ABSBookmarkStore.Delete (Pool.Exec
never returns it; the prior guard was misleading dead code).
- Reject ±Infinity in parseBookmarkTime so a DELETE /…/bookmark/Inf
URL path is consistent with the POST/PATCH body path (which JSON
itself excludes — JSON has no Infinity literal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When deps.Pool is non-nil, construct the pgx-backed store and pass
it through to the ABS handler. Mirrors the other store wirings in
BuildABSHandler; when no pool is available (tests, minimal fixtures),
BookmarkStore stays nil and the handlers respond 503.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements abs.BookmarkStore against abs_bookmarks (migration 148).
COALESCE-to-sentinel-UUID matches the table's unique index for
profile NULL collapsing. Upsert is one round-trip via INSERT ... ON
CONFLICT ... DO UPDATE RETURNING.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registers POST/PATCH/DELETE /me/item/{itemId}/bookmark inside the
existing bearerAuth group so real ABS clients hitting either prefix
resolve to the same handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the three reason discriminators (bookmark_created /
bookmark_updated / bookmark_deleted) documented in
docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md §4. Asserts
event count, scope (event userID), event name, and payload shape.
DELETE event uses the pre-delete snapshot so clients keep the title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent: returns 200 with the caller's current bookmark list
regardless of whether the row existed. Skips item validation so
bookmarks remain removable even after the underlying item is deleted.
Realtime user_updated event with reason=bookmark_deleted fires only
when a row actually existed (carries the pre-delete title).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives the same handleUpsertBookmark with reason="bookmark_updated",
asserts the (user, profile, item, time) tuple is unique (PATCH updates
title in place, never duplicates) and that the ULID is preserved
across upsert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helper was added in advance of the DELETE handler (next task in
the plan) but is unused in this commit, which trips golangci-lint's
unused check. Reintroduce it together with its first caller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of three ABS bookmark endpoints. Body { title, time } upserts on
(user, profile, item, time); response is the item's full bookmark
list. Backed by a new BookmarkStore dependency (nil-safe: handler
returns 503 when unwired). Item validation via MediaStore;
realtime user_updated event with reason=bookmark_created on success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defines the storage contract and wire-shape serialiser the bookmarks
handlers will consume. Envelope test asserts the six required keys
(id, libraryItemId, time, title, createdAt, updatedAt) and the
JS-epoch-millis timestamp shape ABS Android pattern-matches on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: the official ABS Android client (PlaybackSession.kt:194-200)
on ABS server v2.22.0+ with DirectPlay builds the streaming URL as
"$serverAddress/public/session/$id/track/$index" WITHOUT a token,
ignoring audioTrack.contentUrl entirely. Silo reports version 2.35.0
and emits playMethod: 0 (DIRECTPLAY) but never mounted this route, so
every play attempt 404'd silently — spinner forever.
Add handlePublicTrack: look up the session by sid (ULID as capability,
matches booklore-ng + continuum-plugin behavior), resolve the track by
1-based index against the session's media files, stream via
playback.ServeDirectPlay (Range + HEAD supported). Mounted OUTSIDE
bearerAuth at both /public/session/... and /abs/public/session/... .
6 unit tests cover serve / HEAD probe / unknown session / closed
session / out-of-range / bad-index paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- loginEnvelope now takes the caller's time.Now() rather than computing
its own. completeLogin and handleABSAuthorize each pass the same
instant they use elsewhere, so the user.lastSeen/createdAt timestamps
share a single moment with the token ExpiresAt the caller persisted —
no more two-call drift in the same response.
- buildFilterData: add a comment explaining why the parallel author/series
blocks aren't extracted into a helper (different types, different store
methods — a generic version costs more LoC than it saves).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- loginEnvelope: add a comment explaining the displayName→userID fallback.
- handleRefresh: stop leaking internal err detail in 500 responses; log
via slog and return a sanitized "token mint/persist/rotation failed".
- handlePlayStart: document why no "progress" field on playbackSession
(canonical omits it; spec was over-specified).
- Extract resolveDefaultLibrary helper to dedupe the
"first-audiobook-lib-else-virtual" snippet from three handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The plan documented both behaviors but neither was exercised:
- Concurrent rotation: two clients presenting the same refresh token whose
GetTokenByJTI lookups both complete before either revoke must both
succeed with distinct new pairs. Uses a barrierStore that gates the
first Revoke so the test is deterministic instead of relying on the
scheduler.
- Revoke failure: if RevokeTokenByJTI errors after the new pair is
persisted, /auth/refresh returns 500 and the OLD JTI stays valid so
the client can retry without losing access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks 2 and 3 enriched the login envelope to match the real ABS shape but
shipped without tests; a regression dropping seriesHideFromContinueListening,
itemTagsAccessible, or any of the permissions/serverSettings keys would
silently land and only surface on a live device. Cover the marshaled JSON
shape (top-level keys + user + permissions + serverSettings), the
x-return-tokens opt-in path, and the displayName fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logout was sitting inside the bearerAuth group, so a client whose access
token had expired — the primary "I want to sign out" UX moment —
received 401 instead of being able to revoke. handleLogout now parses
the bearer locally and ALWAYS returns 204, mirroring continuum-plugin
canonical behavior. Mounted at /logout, /api/logout, /abs/api/logout,
and the legacy /abs/api/auth/logout path; signature is still verified
so an attacker can't revoke a victim JTI by forging the token shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously deps.Items / deps.Files were conditionally constructed into a
MediaStore, leaving it nil when either was missing. Most ABS handlers
deref the store unconditionally on the request hot path, so a
misconfigured deployment would pass /login then panic on the next request.
Two scattered nil-guards (buildFilterData, loginEnvelope) masked the
problem without fixing it.
- BuildABSHandler panics at startup if Items/Files are missing.
- abs.New panics if MediaStore is nil.
- Remove the two nil-guards (production now always has a store).
- noopMediaStore test fake for handlers that don't exercise catalog reads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final review follow-up for ABS Phase 0. Five fixes:
1. loginEnvelope and handlePersonalized now nil-guard h.deps.MediaStore
so a partially-wired deployment doesnt panic on /login or /personalized.
2. handleLibraryAuthors and handleLibrarySeries respect ABS limit=0
("return all") instead of returning an empty slice.
3. buildSiloAudioTracks now sets MetaTags to map[string]string{} so the
"spinner forever" failure mode types.go warns about cannot bite the
play-session response (item-detail path already sets it; play path
silently omitted the key).
4. slog key normalised from "error" to "err" in the play-session
persist-failure log so log parsers find it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mounted inside bearerAuth so the JTI is already validated. Revokes the
access JTI in abs_sessions and returns 204. Idempotent: re-calling on an
already-revoked JTI still returns 204. Refresh JTI is intentionally NOT
revoked here — clients that want hard sign-out-everywhere will use the
sessions endpoint added in Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile clients call refresh every ~22h to avoid the 24h access-token
re-login trap. Accepts the token via x-refresh-token header (real ABS
convention) or {refreshToken} body (legacy). Mints a fresh pair, persists
both new JTIs, then revokes the old refresh JTI atomically — if any step
in 3-4 fails, the old refresh stays valid and the client can retry.
Returns the user{accessToken, refreshToken} object AND top-level token
fields so mainline and 3rd-party clients both find their expected shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handlePlayStart now looks up the persisted progress row and emits the
saved currentTime in the playback session manifest. Without this every
play start began at 0, breaking cross-device resume which is one of the
core ABS-app value props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cap shadowed the Go builtin. fetchCap matches the naming used elsewhere
in the file for the same kind of over-fetch ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleLibraryDetail now populates filterdata.authors and filterdata.series
from MediaStore so the iOS filter sheet has real options. Narrators,
genres, publishers, languages, tags stay empty arrays for now (Phase 1
will index those aggregations); empty arrays are gracefully handled by
the client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review follow-up to c0e9229. handleLibraryAuthors and handleLibrarySeries
fetched the page slice and reported len(results) as total, hiding the
next-page button for libraries with > 50 authors / > 25 series. Both
now over-fetch (cap 5000) and paginate locally so total is the real DB
row count. Also adds the "tags" key (and confirms "genres") on the play
session mediaMetadata map so strict 3rd-party clients dont crash on
undefined; this completes the empty-array guarantee Task 4 began on the
browse/detail surface. Strengthens the series slug test to pin actual
slugify output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3rd-party ABS clients (Plappa, AudioBookShelfFully) require id on every
authors[] and series[] entry to encode filter selections; missing IDs
made author/series chips dead-end. Also ensures genres and tags are
always non-nil arrays so clients that branch on .length don't crash.
Tags is empty for now (silo has no item-tag concept); shape is stable so
future tag work won't break clients.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-up to 36f68d5: the loginEnvelope refactor dropped the
x-return-tokens compatibility comment, and handleABSAuthorize's reuse of
a.UserID for displayName looked like a copy-paste. Both now explained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the shared envelope builder so /authorize emits the same shape
as /login including accessToken (echoes the caller's bearer), libraries,
permissions, and full serverSettings. The previous /authorize omission
caused iOS resume-on-launch to fall back into re-login.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces second time.Now() call with now.UnixMilli() using the now var
already created higher up in completeLogin for token TTL math.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds itemTagsAccessible, itemTagsSelected, seriesHideFromContinueListening,
lastSeen, createdAt to the user object. Expands permissions to the eight
keys real ABS emits. Enriches serverSettings with the dozen-plus flags
official iOS/Android apps branch on (coverAspectRatio, dateFormat,
timeFormat, scannerDisableWatcher, chromecastEnabled, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the pre-existing cred-validator error log into line with the new
keys added in cd4f629 (all use "err"). Adds "path" to the jwt-secret
fetch error log so it matches its sibling rejection logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each rejection branch in bearerAuth now emits a slog line so failures
are traceable from journalctl. Login success path emits a debug line
confirming token persistence; this makes "I cant login" debuggable
without a tcpdump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detail page:
- collapse Chapters section behind header toggle (73-chapter pages no
longer push content below the fold)
- square cover frames throughout (audiobook covers are Audible-style 1:1,
not 2:3 book portrait)
- narrator picker dropdown when multiple narrations of the same book exist
- clickable genre badges (route to /audiobooks?genre=X)
- reordered so credits/rails sit above the chapter list
- Play-from-Start button forces remount via playToken counter (was a
no-op when player was already at position 0)
- regression test for the Play-from-Start fix
Mini bar / Now Listening:
- mini bar respects --app-sidebar-offset so it stops getting covered by
the desktop sidebar
- Now Listening adds overflow scroll + a labeled "Back to player" button
so controls are never inaccessible on short viewports
Library page:
- infinite scroll (replaces Previous/Next pagination)
- genre filter chip with X-to-clear
Scanner:
- 8-worker parallel reconcile (env SILO_AUDIOBOOK_SCAN_WORKERS to override)
- file-path-first dedup so cleaned titles don't collapse separate
narrations
- title cleanup at write time (strips "Read by X" / "(unabridged)"
suffixes; original tag preserved in original_title)
- audiobook_series upsert from tag-derived series_name/series_position
- secondary dedup check (author + narrator + year + duration ±0.5% +
title-prefix) so two folders of the same book attach to one row
Backend detail handler:
- new fetchAlsoByAuthor, fetchInSeries (with series row > 1 entry guard),
fetchSimilar (embedding-first with shared-genre fallback),
fetchOtherNarrations (regex-strips narrator suffix to group siblings)
- audiobookDetailResponse gained also_by_author, in_series,
similar_audiobooks, other_narrations fields
- list endpoint accepts a genre query param
Embeddings:
- BuildEmbeddingText branches on item.Type == "audiobook" to use
author/narrator credits instead of cast/director/writer
- mediaTypeLabel helper centralizes movie / "TV series" / audiobook
- ListEmbeddingTextCandidates SQL mirrors the Go branching exactly
- ItemsNeedingEmbedding and TotalMediaItemCount loosen status='matched'
gate to also include audiobooks (which don't go through TMDB match)
- FindSimilar gains a mediaType filter so cross-type results never appear
- callers in similar.go and personal.go pass the source item's type
Collections:
- MediaAudiobook MediaKind + audiobook(s) case in templateEligibleForLibrary
(stops offering broken movie/TV templates to audiobook libraries)
- useAddItemToCollection hook (user + admin/library endpoints)
- AddToCollectionDialog wired into audiobook detail and movie/series
ActionBar overflow menu
- ManualCollectionItemsEditor gained a search-and-add panel with
debounced live results
- QueryDefinition.media_scope, QuerySortRelevanceScope, ALL_MEDIA_SCOPES
extended to include "audiobook"
- CatalogFilterBar gained an Audiobooks media scope option
- parseCatalogMediaScope (backend) accepts "audiobook"
Migrations:
- 145_audiobook_series: per-book series_name/series_index with a
best-effort title-pattern backfill for the existing corpus
- 146_audiobook_title_cleanup: strips narrator suffix / (unabridged)
noise from existing titles, preserving raw in original_title
Scripts:
- scripts/dedup_audiobooks.py: one-shot merge for "Title" vs
"Title: Subtitle" duplicates, file-path-stable, dry-run by default
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted