docs(audiobooks): Phase 1 sub-project 3 — ABS smart collections spec

Rule-based dynamic audiobook collections. New smartcoll DSL package
ported from continuum (15-field catalog, 5 personalized; 7 operators
including in_last relative time; 9-field sort catalog with seeded
random). 6 REST endpoints. In-memory eval for our library scale;
SQL pushdown deferred to Phase 4. Migration 153.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 20:10:01 +02:00
co-authored by Claude Opus 4.7
parent 7a8749739a
commit b76be2c153
@@ -0,0 +1,377 @@
# ABS Smart Collections — Phase 1 Sub-Project 3
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Third sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor specs:** `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`. Conventions established there (anti-enumeration 404, `io.LimitReader(1<<20)` body cap, `errors.Is(err, pgx.ErrNoRows)` store pattern, profile-scoped + cross-user-public read, 200 OK on POST, in-package test fakes + `dispatchABSWithParams`) are reused without re-justification.
Commands in this document assume the repository root is the cwd.
## 1. Goal
Land rule-based "smart" audiobook collections on the silo ABS surface so the official Audiobookshelf clients can define dynamic groupings ("books from 2020-2024 by Brandon Sanderson", "audiobooks I've started but haven't finished", "5-star fantasy under 12 hours"). Rules are stored as a JSON DSL; the items endpoint evaluates the rules against the catalog at request time and returns paginated `LibraryItem` results.
## 2. Non-goals
- **No SQL pushdown.** Evaluation walks the candidate list in Go. silo's audiobook library is small enough (hundreds of items) that linear scan per request is fine. SQL pushdown is a Phase 4 follow-up if the library grows past ~5000 items.
- **No rule-builder UI.** Clients ship their own; silo just stores and evaluates `query_def`.
- **No podcast smart collections.** The DSL catalog is audiobook-domain only (title/author/narrator/series/genre/...). Podcast smart collections, if ever needed, get their own DSL.
- **No socket events.** Matches the manual-collections decision in sub-project 2. Phase 2 covers full event parity.
- **No saved-search aliases.** A smart collection is fully described by its `query_def`; there's no separate "saved query" entity.
- **No background materialisation.** Each `/items` call re-evaluates. If perf matters later, add a 30s in-memory result cache keyed on `(collectionID, userID)`.
## 3. Architecture
Three layers, sharing the established silo ABS structure:
- **DSL layer (new package `internal/audiobooks/smartcoll/`):**
- `query.go` — `QueryDefinition`, `QueryGroup`, `QueryRule`, `QuerySort` types + field/sort catalogs + `Normalize`/`Validate`/`MarshalJSON`. Ported from continuum's `internal/smartcoll/query.go` verbatim except for the import path.
- `evaluator.go` — `Candidate`, `EvaluateOptions`, `Evaluate(ctx, qd, candidates, opts) []Candidate`. Pure function, no I/O. Ported from continuum.
- `query_test.go` + `evaluator_test.go` — port continuum's test suites; remove any references to continuum-specific backend types.
- **HTTP layer (`internal/audiobooks/abs/`):**
- `smart_collections_handler.go` — six handlers: `handleListSmartCollections`, `handleCreateSmartCollection`, `handleGetSmartCollection`, `handleSmartCollectionItems`, `handleUpdateSmartCollection`, `handleDeleteSmartCollection`.
- `smart_collections_handler_test.go` — in-memory `memSmartCollectionStore` + handler tests reusing `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher` from prior sub-projects' test files.
- `smart_collections_envelope_test.go` — wire-shape marshal test.
- **Storage layer:**
- `internal/audiobooks/abs/smart_collections.go` — `SmartCollectionStore` interface + `SmartCollection` Go model + `smartCollectionToABS` serialiser.
- `internal/audiobooks/abs_smart_collection_store.go` — pgx-backed `ABSSmartCollectionStore` (parallel to `abs_collection_store.go`).
- **Migration:** `153_abs_smart_collections.up.sql` + `.down.sql`.
- **Bookmark store extension:**
- `BookmarkStore.CountByUser(ctx, userID, profileID) (map[string]int, error)` returning `{libraryItemID: count}` for batch hydration of the personalized `bookmark_count` rule. Single SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2, sentinel) GROUP BY library_item_id`.
- **Service wiring:** `BuildABSHandler` constructs the new store; field lands on `abs.Dependencies` next to `PlaylistStore`.
## 4. Endpoint surface
All routes mounted under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** unless the `Returns` column says otherwise.
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/me/smart-collections` | — | `{"items": [SmartCollection list-shape]}` |
| POST | `/me/smart-collections` | `{name, description?, color?, isPublic?, isPinned?, query_def}` | SmartCollection full-shape |
| GET | `/me/smart-collections/{id}` | — | SmartCollection full-shape when owner OR `isPublic=true`; otherwise 404 |
| GET | `/me/smart-collections/{id}/items` | (query: `?limit=`, `?page=`) | Paged envelope of `LibraryItem` results from rule evaluation |
| PATCH | `/me/smart-collections/{id}` | `{name?, description?, color?, isPublic?, isPinned?, query_def?}` | SmartCollection full-shape |
| DELETE | `/me/smart-collections/{id}` | — | 204 No Content |
### 4.1 Wire shape — SmartCollection
**List-shape and full-shape are identical** (unlike manual collections / playlists where the list-shape omits child items). Smart collections have no stored items; the items are computed by the separate `/items` route. So one shape:
```json
{
"id": "01HSC...",
"userId": "1",
"name": "Recent Fantasy",
"description": "fantasy added in the last 6 months",
"color": "#3b82f6",
"isPublic": false,
"isPinned": true,
"queryDef": {
"library_ids": [9],
"match": "all",
"groups": [{
"match": "all",
"rules": [
{"field": "genre", "op": "contains", "value": "Fantasy"},
{"field": "added_at", "op": "in_last", "value": "180d"}
]
}],
"sort": {"field": "added_at", "order": "desc"},
"limit": null
},
"createdAt": 1779786284823,
"updatedAt": 1779786284823
}
```
All ten top-level keys always present (`description`, `color` default to `""`; `isPublic`, `isPinned` default to `false`; `queryDef` is the parsed JSON, never raw bytes on the wire). Timestamps are JS-epoch milliseconds.
### 4.2 Wire shape — `/items` envelope
Standard ABS paged envelope (`pagedEnvelope` helper already in `handler.go`):
```json
{
"results": [ /* hydrated LibraryItem objects */ ],
"total": 42,
"limit": 30,
"page": 0,
"sortBy": "added_at",
"sortDesc": true,
"filterBy": "",
"minified": false,
"include": ""
}
```
`results` items use the same `siloItemToLibraryItem` shape as `/api/libraries/{id}/items`. Pagination is post-eval slice (eval order is preserved; pages are stable for a given `(collection, user)` as long as the underlying catalog doesn't change).
### 4.3 List-envelope wrap key
Smart-collection LIST uses `{"items": [...]}` — matches continuum's wrap key (which differs from manual collections' `{"collections": [...]}`). Clients pattern-match on the wrap key when distinguishing surfaces; don't override.
## 5. DSL surface (full vocabulary)
### 5.1 Fields (15 total)
**Non-personalized (10):**
| Field | Type | Valid ops | Source |
|---|---|---|---|
| `title` | scalar string | `is`, `is_not`, `contains` | `media_items.title` |
| `author` | array of strings | `is`, `is_not`, `contains` | item people: role=author |
| `narrator` | array of strings | `is`, `is_not`, `contains` | item people: role=narrator |
| `series` | array of strings | `is`, `is_not`, `contains` | `audiobook_series` |
| `genre` | array of strings | `is`, `is_not`, `contains` | `media_items.genres` |
| `year` | int | `is`, `is_not`, `gt`, `gte`, `lt`, `lte`, `between` | `media_items.release_year` |
| `rating` | float | `gt`, `gte`, `lt`, `lte`, `between` | `media_items.rating_imdb` (or whichever rating silo populates) |
| `language` | scalar string | `is`, `is_not` | `media_items.original_language` |
| `publisher` | scalar string | `is`, `is_not`, `contains` | `media_items.publisher` |
| `added_at` | timestamp | `gt`, `lt`, `between`, `in_last` | `media_items.added_at` |
| `duration_seconds` | int | `gt`, `gte`, `lt`, `lte`, `between` | sum of media_files.duration for the item |
**Personalized (5)** — require `AllowPersonalized: true` (only when caller is the collection's owner):
| Field | Valid ops | Source |
|---|---|---|
| `finished` | `is` | `user_watch_progress.is_finished` |
| `in_progress` | `is` | progress exists AND `is_finished == false` AND `current_seconds > 0` |
| `last_played` | `gt`, `gte`, `lt`, `lte`, `between`, `in_last` | `user_watch_progress.updated_at` |
| `abandoned` | `is` | `in_progress == true` AND `last_played < now() - 60d` (configurable via `EvaluateOptions.AbandonedAfter`) |
| `bookmark_count` | `gt`, `gte`, `lt`, `lte`, `between` | count of `abs_bookmarks` rows for (user, profile, item) |
### 5.2 Sort fields
`title` (asc), `added_at` (desc), `year` (desc), `duration_seconds` (desc), `rating` (desc), `random` (deterministic shuffle seeded by `userID + ":" + collectionID`), and personalized: `progress` (desc), `last_played` (desc), `plays` (desc).
### 5.3 Operators
- `is` / `is_not` — equality (case-insensitive for strings; numeric otherwise). For array fields, `is` matches when ANY element equals the value.
- `contains` — substring (case-insensitive) for scalar strings; element-substring for array fields.
- `gt` / `gte` / `lt` / `lte` — numeric or timestamp comparison.
- `between` — `value` is a 2-element array `[low, high]`, inclusive bounds.
- `in_last` — relative window. `value` is a duration string (`"7d"`, `"180d"`, `"24h"`, `"4w"`). Evaluator parses to `time.Duration`; field must be ≥ `opts.Now - duration`.
### 5.4 Aliases
`authors` → `author`, `narrators` → `narrator`, `genres` → `genre` for fields. `sort_title` → `title`, `recently_added` → `added_at`, `duration` → `duration_seconds` for sorts. Applied in `Normalize()`; older clients with plural / verbose names keep working.
## 6. Data model
### 6.1 Migration 153
`migrations/153_abs_smart_collections.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
is_pinned boolean NOT NULL DEFAULT false,
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
ON public.abs_smart_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/153_abs_smart_collections.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_smart_collections;
```
### 6.2 Schema rationale
- **`query_def jsonb`** — JSONB so future GIN-indexed JSON path probes are an option; default `'{}'::jsonb` so rows with no rules are queryable without NULL guards.
- **No items table** — smart collections have no stored children; items are computed at eval time.
- **`color`, `is_pinned`** — UI decorations. silo doesn't validate the color format.
- **No `library_ids` column** — that list is part of the DSL, lives inside `query_def`.
### 6.3 Go model
```go
type SmartCollection struct {
ID string
UserID string
ProfileID string
Name string
Description string
Color string
IsPublic bool
IsPinned bool
QueryDef []byte // raw JSONB bytes; decoded on the items path
CreatedAt time.Time
UpdatedAt time.Time
}
```
## 7. Storage contract
```go
type SmartCollectionStore interface {
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
CreateSmartCollection(ctx context.Context, c SmartCollection) error
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
DeleteSmartCollection(ctx context.Context, id string) error
}
```
Behavior follows the established silo pattern: ordered by `created_at DESC`, empty slice never nil, `GetSmartCollection` returns `ErrNotFound` without owner check (handler authorizes), `Update` sets `updated_at = now()`, `Delete` returns nil even on no-match.
**Extension to existing `BookmarkStore`:**
```go
// CountByUser returns a map of library_item_id → bookmark count for
// the given (user, profile). Empty map (never nil) when none.
// Used by the smart-collection items evaluator to hydrate the
// personalized `bookmark_count` rule in one SQL pass.
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
```
SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2::uuid, sentinel) GROUP BY library_item_id`.
## 8. Items evaluation flow
`handleSmartCollectionItems` runs:
1. Auth + store-nil + URL `{id}` checks (same boilerplate as other handlers).
2. `GetSmartCollection(ctx, id)` → 404 on ErrNotFound OR `(non-owner AND !isPublic)`.
3. Decode `c.QueryDef` → `smartcoll.QueryDefinition`. 500 on malformed (shouldn't happen — `Validate` is called on persist; defensive guard).
4. Resolve target libraries: `qd.LibraryIDs` if non-empty, else every audiobook library from `MediaStore.ListAudiobookLibraries`.
5. For each target library, fetch the audiobook list via `MediaStore.ListAudiobooks(ctx, libID, 5000, 0)`. (5000 cap matches continuum's; silo's libraries are smaller.)
6. **Build Candidates.** For each item:
- `Candidate.Item` = the `*models.MediaItem`.
- If `c.UserID == a.UserID` (owner): hydrate per-user state.
- Fetch all progress rows: `ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 10000)`. Build `map[contentID]ProgressRow`.
- Fetch bookmark counts: `BookmarkStore.CountByUser(ctx, a.UserID, a.ProfileID)`. Map `contentID → count`.
- Populate `IsFinished`, `ProgressPct`, `CurrentSeconds`, `LastPlayedAt`, `BookmarkCount`.
- Non-owner viewing public collection: skip hydration; personalized rules eval against zero-values.
7. `smartcoll.Evaluate(ctx, qd, candidates, EvaluateOptions{AllowPersonalized: c.UserID == a.UserID, UserSeed: a.UserID + ":" + c.ID, Now: time.Now(), AbandonedAfter: 60*24*time.Hour})` → matched + sorted.
8. Paginate: `limit, page := readPagedQuery(r, 30)`. Override default limit with `qd.Limit` when `?limit=` query param is absent. Slice `matched[page*limit : (page+1)*limit]`.
9. Hydrate each result via the existing `siloItemToLibraryItem` helper (same as `/api/libraries/{id}/items`).
10. Wrap in `pagedEnvelope`. Write 200.
### 8.1 Performance budget
- Library fetch: one SQL query per library (~hundreds of rows).
- Progress hydration: one SQL query for the user's full progress list.
- Bookmark hydration: one SQL query for the aggregated counts.
- Total: 2 + N (libraries) queries, all paginated/limited at the store layer. silo's current 1-library config means 3 total queries per `/items` request. Fast enough.
## 9. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| `name` missing on POST | 400 | `name required` |
| `query_def` fails `Validate(allowPersonalized=true)` | 400 | `invalid query_def: <reason>` (reason from validator — safe to surface) |
| Unknown collection on GET (owner or not) | 404 | `smart collection not found` |
| Non-owner GET on private | 404 | same body — no leak |
| Non-owner PATCH/DELETE/items | 404 | same body |
| Store mutate fails | 500 | `smart collection persist failed` (delete: `smart collection delete failed`); err logged via `slog.Error` |
| `/items` eval — `MediaStore.ListAudiobooks` fails for a library | log `slog.Warn` and skip that library | partial results beat 500 |
| Personalized rule on non-owner-public eval | silently dropped at `smartcoll.Evaluate` via `AllowPersonalized: false` | no error to the client |
**Cross-cutting:** Body size limit `io.LimitReader(r.Body, 1<<20)` on all POST/PATCH. `validate(allowPersonalized=true)` runs on every persist call; on read, the items handler passes `AllowPersonalized = (c.UserID == a.UserID)` so saved personalized rules are non-fatally dropped when viewed by non-owners.
## 10. Testing
### 10.1 DSL unit tests (`internal/audiobooks/smartcoll/`)
Port continuum's `query_test.go` and `evaluator_test.go` verbatim. The continuum reference covers:
- `Normalize`: aliasing, lowercase, dedupe library_ids, default-match `"all"`, default-sort `"added_at"`.
- `Validate`: unknown field, invalid op for field, personalized rule without scope, invalid sort field, invalid sort order, negative limit.
- `Evaluate`: each operator for each field type, AND/OR combinator for groups and definition, personalized rules drop when `AllowPersonalized: false`, `random` sort stability per `UserSeed`, `Limit` honored, empty rules match-everything.
### 10.2 Handler tests (`smart_collections_handler_test.go`)
In-memory `memSmartCollectionStore` (parallel to `memCollectionStore`); reuses `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher`. The bookmark store extension (`CountByUser`) needs a matching method on `memBookmarkStore` from sub-project 1's test file — extend that fake.
| Test | Asserts |
|---|---|
| `SmartCollection_Create_ReturnsFullShape` | POST → 200, 10 top-level keys present, ULID, queryDef round-tripped as a nested object (not raw bytes) |
| `SmartCollection_Create_NameRequired_400` | empty name → 400 |
| `SmartCollection_Create_InvalidBody_400` | malformed JSON → 400 |
| `SmartCollection_Create_InvalidQueryDef_400` | `{"groups":[{"rules":[{"field":"nonsense","op":"is","value":1}]}]}` → 400 with reason in body |
| `SmartCollection_Create_PersonalizedRuleAllowed` | rule with `field:"finished"` accepted (persist always runs with `allowPersonalized=true`) |
| `SmartCollection_List_WrappedAsItems` | GET → `{"items": [...]}` envelope key (NOT `"collections"`) |
| `SmartCollection_List_DoesNotLeakOtherUsers` | user 1 creates; user 2 lists → empty |
| `SmartCollection_List_ProfileIsolation` | profile A creates; profile B lists same user → empty |
| `SmartCollection_Get_Owner_ReturnsFullShape` | owner GET → 200 + full-shape |
| `SmartCollection_Get_NonOwner_Public_OK` | public GET by other user → 200 |
| `SmartCollection_Get_NonOwner_Private_404` | private GET by other user → 404 (anti-enumeration) |
| `SmartCollection_Get_Unknown_404` | unknown ID → 404 |
| `SmartCollection_Patch_Owner_UpdatesFields` | partial PATCH updates only present fields |
| `SmartCollection_Patch_NonOwner_404` | non-owner PATCH → 404, original untouched |
| `SmartCollection_Patch_InvalidQueryDef_400` | PATCH with bad query_def → 400 |
| `SmartCollection_Delete_Owner_204` | DELETE → 204; subsequent GET → 404 |
| `SmartCollection_Delete_NonOwner_404` | non-owner DELETE → 404, original still present |
| `SmartCollection_Items_Owner_EvaluatesRules` | seed 3 audiobooks (two match the rule, one doesn't); GET /items returns 2 results |
| `SmartCollection_Items_PersonalizedDroppedForNonOwner` | public collection with `field:"finished"` rule — non-owner sees ALL matching books (personalized rule silently dropped); owner sees only finished |
| `SmartCollection_Items_PaginatedEnvelope` | GET /items → standard pagedEnvelope with 9 fields |
| `SmartCollection_Items_RespectsQueryDefLimit` | qd.Limit=5; GET without ?limit returns 5; GET with ?limit=10 returns 10 (request overrides) |
| `SmartCollection_Items_NonOwner_Private_404` | private collection /items by non-owner → 404 |
| `SmartCollection_Envelope_HasRequiredKeys` | marshal-test for 10 top-level keys with empty defaults |
### 10.3 Live integration smoke (operator)
```bash
TOKEN=$(curl ... /login | jq -r .accessToken)
# Create a smart collection: "fantasy added in the last 6 months"
SC=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"recent fantasy","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"contains","value":"Fantasy"},{"field":"added_at","op":"in_last","value":"180d"}]}],"sort":{"field":"added_at","order":"desc"}}}' \
http://127.0.0.1:13378/api/me/smart-collections | jq -r .id)
# Evaluate
curl ... /api/me/smart-collections/$SC/items | jq '.results | length'
# Patch name
curl -X PATCH ... -d '{"name":"renamed"}' /api/me/smart-collections/$SC | jq .name
# Delete
curl -X DELETE ... /api/me/smart-collections/$SC # 204
```
### 10.4 Out of scope
- No real-Postgres SQL test (same posture as prior sub-projects).
- No GIN-index on `query_def`. Phase 4 follow-up if rule-querying becomes a feature.
- No performance test. Library is small; linear scan is fine.
## 11. Risks & open questions
- **5000-item per-library cap.** If a library exceeds this, the over-fetch silently truncates. silo's libraries are well below this today; if it changes, switch to paged ListAudiobooks + accumulate.
- **Personalized rule eval against non-owners is silent.** A non-owner viewing a public collection sees results that don't honor the owner's personalized rules. This is the privacy-correct behavior, but a confused user might wonder why "books I haven't finished" shows everything when viewed by their friend. Document in the parent spec's UX notes.
- **`random` sort + pagination.** Two requests with the same `(user, collection)` produce the same shuffle (seeded). Browser-back / page-2 stays consistent. If the catalog mutates between requests, the seed still works but new items land in unpredictable positions — acceptable.
- **`query_def` migration on field-catalog changes.** If silo renames a field or removes an op in the future, existing rows persist the old vocab. `Validate` would reject them, but Normalize won't drop them — we'd surface stale rules as a runtime warning in the items handler. Acceptable for v1.
- **`description` length.** No cap; relies on the 1 MiB body limit. Same posture as other surfaces.
## 12. Out-of-scope follow-ups
- SQL-pushdown evaluator for large libraries.
- GIN-indexed JSON path queries on `query_def`.
- Background materialisation cache for hot smart collections.
- Cross-collection composition ("collection A + collection B").
- Result count badges on `/me/smart-collections` (would require running eval on every list call — expensive).
- Audiobook recommender hooks (the `relevance` sort placeholder in continuum).
## 13. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Sibling sub-project specs: `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`.
- DSL reference: `continuum-plugin-audiobooks/internal/smartcoll/{query,evaluator}.go` + matching tests (port verbatim).
- HTTP reference: `continuum-plugin-audiobooks/internal/abs/smart_collection_handler.go`.