* 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 asd59c1cb(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 aseb8f67d(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>
52 KiB
ABS Bookmarks 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: Land the three Audiobookshelf-compatible bookmark endpoints (POST/PATCH/DELETE) plus realtime socket events, so the official ABS Android, ABS iOS, and Plappa clients can create, edit, and delete bookmarks against a silo audiobook library.
Architecture: New migration 148_abs_bookmarks adds a postgres-backed abs_bookmarks table keyed on (user, profile, item, time). New BookmarkStore interface in internal/audiobooks/abs/ with an in-memory fake for tests and a concrete ABSBookmarkStore (pgx) for production. New bookmarks_handler.go with one upsert handler (shared by POST/PATCH via a reason-parameterised closure) and one delete handler. Routes mount under both /abs/api/* and /api/* inside the existing bearerAuth group. Socket events ride on the existing nil-safe Handler.publish wrapper.
Tech Stack: Go 1.x, chi/v5 router, pgx/v5, oklog/ulid/v2, internal package abs tests with in-memory fakes.
Commands assume the repository root (/opt/silo-server) is the cwd.
Source spec: docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md. Re-read sections 4 (endpoint table), 5 (data model), 6 (storage contract), and 7 (error model) before each task — the plan implements the spec, it does not re-justify decisions already made there.
File map
Create:
migrations/148_abs_bookmarks.up.sqlmigrations/148_abs_bookmarks.down.sqlinternal/audiobooks/abs/bookmarks.go—Bookmarkstruct,BookmarkStoreinterface,bookmarkToABSserialiser.internal/audiobooks/abs/bookmarks_handler.go—handleUpsertBookmark(reason) http.HandlerFunc,handleDeleteBookmark.internal/audiobooks/abs/bookmarks_handler_test.go— in-memory fakememBookmarkStore, recordingrecordingPublisher, dispatch helper, all 13 spec tests.internal/audiobooks/abs/bookmarks_envelope_test.go— wire-shape test forbookmarkToABS.internal/audiobooks/abs_bookmark_store.go—ABSBookmarkStore(pgx-backed concrete impl).
Modify:
internal/audiobooks/abs/handler.go— addBookmarkStorefield onDependencies; register the three routes inside the existingbearerAuthgroup at the bottom ofmountRoutes.internal/audiobooks/service.go— construct&ABSBookmarkStore{Pool: deps.Pool}and pass it intoabs.DependenciesinsideBuildABSHandler.
Task 1: Migration 148 — abs_bookmarks
Files:
-
Create:
migrations/148_abs_bookmarks.up.sql -
Create:
migrations/148_abs_bookmarks.down.sql -
Step 1: Write the up-migration
Create migrations/148_abs_bookmarks.up.sql:
-- ABS bookmark rows. One row per (user, profile, item, time). Backs the
-- POST/PATCH/DELETE /me/item/{itemId}/bookmark endpoints in
-- internal/audiobooks/abs/bookmarks_handler.go.
--
-- profile_id is nullable because silo's "primary profile" is encoded as
-- NULL profile. The COALESCE-to-sentinel-UUID in the unique index
-- collapses NULL to a single bucket per user (raw NULL would be treated
-- as distinct for uniqueness purposes).
CREATE TABLE IF NOT EXISTS public.abs_bookmarks (
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,
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 IF NOT EXISTS abs_bookmarks_user_profile_item_time_uniq
ON public.abs_bookmarks (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_item_id,
time_seconds
);
CREATE INDEX IF NOT EXISTS abs_bookmarks_user_item_idx
ON public.abs_bookmarks (user_id, library_item_id);
- Step 2: Write the down-migration
Create migrations/148_abs_bookmarks.down.sql:
DROP TABLE IF EXISTS public.abs_bookmarks;
- Step 3: Apply locally to verify it parses
Make sure local postgres is running:
docker compose up -d postgres redis
Then apply the migration by booting silo (the server runs migrations on startup). The Makefile target installs deps and compiles ./silo:
make build
./silo --mode integrated 2>&1 | head -50
Expected: a startup log line acknowledging migration 148, no parse errors. Ctrl+C once you see the server settle.
If the project uses a separate migrate tool, the equivalent is docker compose exec postgres psql -U silo -d silo -f /migrations/148_abs_bookmarks.up.sql — but the startup path is the canonical one.
Verify the rollback also parses by applying it manually:
docker compose exec -T postgres psql -U silo -d silo -f - < migrations/148_abs_bookmarks.down.sql
docker compose exec -T postgres psql -U silo -d silo -f - < migrations/148_abs_bookmarks.up.sql
Both should return DROP TABLE / CREATE TABLE etc. with no errors.
- Step 4: Commit
git add migrations/148_abs_bookmarks.up.sql migrations/148_abs_bookmarks.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): add abs_bookmarks migration (148)
Backs the upcoming ABS-compatible bookmark endpoints. Schema and
rationale documented in
docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md §5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Bookmark type, BookmarkStore interface, envelope helper + test
Files:
-
Create:
internal/audiobooks/abs/bookmarks.go -
Create:
internal/audiobooks/abs/bookmarks_envelope_test.go -
Step 1: Write the failing envelope test
Create internal/audiobooks/abs/bookmarks_envelope_test.go:
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)
}
}
- Step 2: Run the test to verify it fails
go test ./internal/audiobooks/abs/ -run TestBookmarkEnvelope_HasRequiredKeys -v
Expected: build failure with "undefined: bookmarkToABS" / "undefined: Bookmark".
- Step 3: Create the type, interface, and envelope helper
Create internal/audiobooks/abs/bookmarks.go:
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
}
// 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(),
}
}
- Step 4: Run the test to verify it passes
go test ./internal/audiobooks/abs/ -run TestBookmarkEnvelope_HasRequiredKeys -v
Expected: --- PASS: TestBookmarkEnvelope_HasRequiredKeys.
- Step 5: Build the whole package to ensure no symbol drift
go build ./...
Expected: no output (clean build).
- Step 6: Commit
git add internal/audiobooks/abs/bookmarks.go internal/audiobooks/abs/bookmarks_envelope_test.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): add BookmarkStore interface + ABS envelope helper
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>
EOF
)"
Task 3: In-memory fake memBookmarkStore, dispatch helper, handleUpsertBookmark (create path)
Files:
-
Modify:
internal/audiobooks/abs/handler.go:163(addBookmarkStorefield toDependencies) -
Create:
internal/audiobooks/abs/bookmarks_handler.go -
Create:
internal/audiobooks/abs/bookmarks_handler_test.go -
Step 1: Add the field to
Dependenciesso the type compiles
Edit internal/audiobooks/abs/handler.go in the Dependencies struct (around line 163). Add this field after PlaybackSessionStore:
// 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
Verify the package still builds:
go build ./...
Expected: clean build.
- Step 2: Write the failing test (and supporting fakes)
Create internal/audiobooks/abs/bookmarks_handler_test.go:
package abs
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"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 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")
}
func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string) (*models.MediaItem, error) {
if it, ok := s.known[id]; ok {
if it == nil {
return &models.MediaItem{ContentID: id}, nil
}
return it, nil
}
return nil, nil
}
// ---------------------------------------------------------------------------
// 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())
}
}
}
- Step 3: Run the test to verify it fails (compile error)
go test ./internal/audiobooks/abs/ -run TestCreate_NewBookmark_ReturnsListContainingIt -v
Expected: build failure with "h.handleUpsertBookmark undefined".
- Step 4: Implement
handleUpsertBookmark
Create internal/audiobooks/abs/bookmarks_handler.go:
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).
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID)
if err != nil || 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)
}
}
// 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)
}
// 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) {
return 0, false
}
return v, true
}
- Step 5: Run the test to verify it passes
go test ./internal/audiobooks/abs/ -run TestCreate_NewBookmark_ReturnsListContainingIt -v
Expected: --- PASS: TestCreate_NewBookmark_ReturnsListContainingIt.
- Step 6: Run the whole package to confirm no other tests regressed
go test ./internal/audiobooks/abs/ -v
Expected: all existing tests still pass.
- Step 7: Commit
git add internal/audiobooks/abs/handler.go internal/audiobooks/abs/bookmarks_handler.go internal/audiobooks/abs/bookmarks_handler_test.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): POST /me/item/{id}/bookmark — ABS bookmark create
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>
EOF
)"
Task 4: PATCH path — handleUpsertBookmark("bookmark_updated")
Files:
- Modify:
internal/audiobooks/abs/bookmarks_handler_test.go(add test)
The PATCH handler is the same function as POST, just constructed with a different reason. This task is "add the test that drives both methods" so the contract is documented.
- Step 1: Write the failing test
Append to bookmarks_handler_test.go:
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"])
}
}
- Step 2: Run the test to verify it passes
go test ./internal/audiobooks/abs/ -run TestUpsert_SameTime_UpdatesTitleNoDuplicate -v
Expected: PASS (the create-path implementation already supports update).
- Step 3: Commit
git add internal/audiobooks/abs/bookmarks_handler_test.go
git commit -m "$(cat <<'EOF'
test(audiobooks): cover PATCH /me/item/{id}/bookmark upsert semantics
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>
EOF
)"
Task 5: DELETE handler — handleDeleteBookmark
Files:
-
Modify:
internal/audiobooks/abs/bookmarks_handler.go(add handler) -
Modify:
internal/audiobooks/abs/bookmarks_handler_test.go(add tests) -
Step 1: Write the failing tests
Append to bookmarks_handler_test.go:
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))
}
}
- Step 2: Run the tests to verify they fail (compile error)
go test ./internal/audiobooks/abs/ -run 'TestDelete_' -v
Expected: build failure with "h.handleDeleteBookmark undefined".
- Step 3: Implement
handleDeleteBookmark
Append to internal/audiobooks/abs/bookmarks_handler.go:
// 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)
}
- Step 4: Run the tests to verify they pass
go test ./internal/audiobooks/abs/ -run 'TestDelete_' -v
Expected: both TestDelete_ExistingBookmark_RemovedFromList and TestDelete_NonExistentTime_IdempotentReturnsEmptyList PASS.
- Step 5: Commit
git add internal/audiobooks/abs/bookmarks_handler.go internal/audiobooks/abs/bookmarks_handler_test.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): DELETE /me/item/{id}/bookmark/{time} — ABS delete
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>
EOF
)"
Task 6: Remaining unit-test coverage
Files:
- Modify:
internal/audiobooks/abs/bookmarks_handler_test.go(add tests)
Adds the remaining spec §8.1 cases: ordering, profile isolation, cross-user no-op, 404 on missing item, 400 on bad bodies.
- Step 1: Write the failing tests
Append to bookmarks_handler_test.go:
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 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())
}
}
- Step 2: Run the tests
go test ./internal/audiobooks/abs/ -run 'TestCreate_Two|TestProfileIsolation|TestDelete_OtherUser|TestMissingItem|TestInvalidBody|TestMissingTime' -v
Expected: all six PASS. If TestProfileIsolation_BookmarksScopedPerProfile fails, the memBookmarkStore.List prefix check is wrong — verify the prefix includes userID|profileID|itemID|.
- Step 3: Run the full package suite to catch any drift
go test ./internal/audiobooks/abs/ -v
Expected: all tests PASS.
- Step 4: Commit
git add internal/audiobooks/abs/bookmarks_handler_test.go
git commit -m "$(cat <<'EOF'
test(audiobooks): cover ABS bookmark edge cases
Adds ordering, per-profile isolation, cross-user no-op (existence
leak guard), missing-item 404, malformed-body 400, and missing-time
400 to the bookmark handler suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: Socket-event tests
Files:
- Modify:
internal/audiobooks/abs/bookmarks_handler_test.go(add tests)
Asserts the realtime user_updated events documented in spec §4: one per
mutation, scoped to the acting user, with the right reason and a
populated bookmark payload.
- Step 1: Write the failing tests
Append to bookmarks_handler_test.go:
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"])
}
}
- Step 2: Run the tests to verify they pass
go test ./internal/audiobooks/abs/ -run 'TestSocketEvent_' -v
Expected: all three PASS (the handlers already call h.publish per Task 3 and Task 5).
- Step 3: Commit
git add internal/audiobooks/abs/bookmarks_handler_test.go
git commit -m "$(cat <<'EOF'
test(audiobooks): assert realtime user_updated events on bookmark ops
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>
EOF
)"
Task 8: Register routes in mountRoutes
Files:
-
Modify:
internal/audiobooks/abs/handler.go(the Stage 4bearerAuthgroup around line 298) -
Step 1: Add the three routes inside the existing bearerAuth group
In internal/audiobooks/abs/handler.go, locate the Stage 4 group (currently registering /me/progress* and /session/{sid}*). At the end of the for _, prefix := range []string{"/abs/api", "/api"} { loop, before the closing brace, append:
// 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)
The result should look like (showing only the bottom of the Stage 4 group):
// 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)
}
})
- Step 2: Build the package
go build ./...
Expected: clean build.
- Step 3: Run the whole audiobooks suite
go test ./internal/audiobooks/...
Expected: all tests PASS.
- Step 4: Commit
git add internal/audiobooks/abs/handler.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): mount ABS bookmark routes at /abs/api and /api
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>
EOF
)"
Task 9: Concrete ABSBookmarkStore (pgx-backed)
Files:
-
Create:
internal/audiobooks/abs_bookmark_store.go -
Step 1: Implement the store
Create internal/audiobooks/abs_bookmark_store.go:
package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/jackc/pgx/v5"
"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)
}
_, 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,
)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("abs_bookmark_store: delete: %w", err)
}
return nil
}
- Step 2: Build
go build ./...
Expected: clean build. If the compile-time assertion fires, the interface and concrete signatures have drifted — re-check internal/audiobooks/abs/bookmarks.go and the methods on ABSBookmarkStore.
- Step 3: Commit
git add internal/audiobooks/abs_bookmark_store.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): ABSBookmarkStore — pgx-backed concrete store
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>
EOF
)"
Task 10: Wire ABSBookmarkStore into BuildABSHandler
Files:
-
Modify:
internal/audiobooks/service.go:90-130(theBuildABSHandlerfunction) -
Step 1: Construct the store and pass it through
In internal/audiobooks/service.go, locate the block that constructs playbackSessionStore (around line 90). Below that block, before the configProvider block, insert:
var bookmarkStore abs.BookmarkStore
if deps.Pool != nil {
bookmarkStore = &ABSBookmarkStore{Pool: deps.Pool}
}
Then in the abs.New(abs.Dependencies{...}) call (around line 121), add the field after PlaybackSessionStore:
PlaybackSessionStore: playbackSessionStore,
BookmarkStore: bookmarkStore,
The diff is just two added lines for construction and one added field on the Dependencies literal.
- Step 2: Build
go build ./...
Expected: clean build.
- Step 3: Run the audiobooks suite one more time
go test ./internal/audiobooks/...
Expected: all tests PASS.
- Step 4: Commit
git add internal/audiobooks/service.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): wire ABSBookmarkStore into BuildABSHandler
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>
EOF
)"
Task 11: Full verification
This is a single-task, multi-step check the engineer runs before considering the work done. No code changes unless something fails.
- Step 1: Run the full Go test suite
go test ./...
Expected: all packages PASS. If anything outside internal/audiobooks/ regresses, fix it before continuing — bookmark code should be independent.
- Step 2: Lint
make lint
Expected: clean.
- Step 3: Frontend lint + format (no frontend changes, but the merge gate requires both)
cd web && pnpm run lint && pnpm run format:check
cd ..
Expected: clean.
- Step 4: Local-paths guard
make verify-local-paths
Expected: clean.
- Step 5: Frontend build (catches what
tsc --noEmitmisses; see CLAUDE.md memory)
cd web && pnpm run build
cd ..
Expected: clean build.
- Step 6: Apply the migration on a clean local DB and roll back
docker compose exec -T postgres psql -U silo -d silo -c "\d abs_bookmarks"
Expected: the table schema printed, matching the migration. Roll back to confirm the down works:
docker compose exec -T postgres psql -U silo -d silo -f - < migrations/148_abs_bookmarks.down.sql
docker compose exec -T postgres psql -U silo -d silo -c "\d abs_bookmarks"
Expected: "Did not find any relation named ..." or equivalent. Then re-apply:
docker compose exec -T postgres psql -U silo -d silo -f - < migrations/148_abs_bookmarks.up.sql
- Step 7: Live integration smoke (spec §8.3)
Boot the server in integrated mode, then run the smoke from the spec. Substitute your local <u>, <p>, and <lid> values.
make build && ./silo --mode integrated &
SILO_PID=$!
sleep 3
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'])")
echo "--- POST create ---"
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
echo "--- PATCH update ---"
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
echo "--- DELETE ---"
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
kill $SILO_PID
Expected:
- POST →
[{"id":"...","libraryItemId":"<ITEM>","time":42.5,"title":"smoke",...}] - PATCH →
[{...,"title":"updated",...}]with the SAMEidas POST. - DELETE →
[].
If the PATCH returns a different id, the unique-index COALESCE clause is wrong — re-check migration 148.
- Step 8: No commit unless something needed fixing
If steps 1-7 all passed cleanly, the branch is ready. If a fix was needed, commit it with a descriptive fix(audiobooks): ... message.
Out of scope (deferred per spec §10)
- Aggregate
GET /api/me/bookmarksendpoint. - Hydrating
user.bookmarksin the/loginenvelope. - Embedding
bookmarksonGET /api/items/{id}.
These belong to later Phase 1 sub-projects.