Files
bookhoard/docs/contract_pins.md
T
John O'Keefe 6750e9e0dc feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.

- Sidecar manifest fingerprints (media_scanner_manifest.go): the
  manifest stats exactly the names the extractors consume (metadata.opf,
  metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
  manifest and extractors can never disagree. Shape v1:
  {v, entries:[{n, s, m (unix nanos — second granularity would hide
  edits landing in the backfill scan's own second), h (sha256 — covers
  included: the repoint ladder needs a baseline hash for any stat-
  drifted sidecar; a hashless cover turns harmless mtime drift into a
  re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
  ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
  reconciliation, stat pair refreshed, no re-extraction; differing =
  changed); NULL stored manifest → backfill only, NO re-extraction
  (deploy-day rule: the first scan after release must not look like an
  mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
  through updateMediaItemWithMetadata (preExtracted — metadata_overrides
  stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
  a reading-order entry added to an EXISTING book now creates its
  collection; add-if-missing is idempotent), store a fresh full
  manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
  change path does not consume sidecar extractors, so gating them would
  flip books 'changed' without picking anything up (plan correction —
  the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.

Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
2026-10-19 20:17:57 -04:00

15 KiB

Wire-contract pins (hardening batches)

These are the response SHAPES (JSON key sets, not values) frozen by the TestContractPin_* suite in cmd/server/tests/contract_pins_test.go (batch H0). The plan they serve is ~/.opencode/plan/hardening-canonical-types.md.

Rules

  1. Frozen: no existing response key may be RENAMED or REMOVED on any endpoint below (JSON or KOReader/Kobo adapters). Requests are frozen too — no parsing changes.
  2. Additive keys are allowed and expected — but a batch must widen the allowed slice in the test AND record the addition in the changelog below, in the same commit.
  3. KOReader / Kobo / progress surfaces are the hard freeze: the KOReader plugin is an external client whose handlers keep response shapes byte-identical (only their internals may route through shared funnels).
  4. A behavior fix that would require narrowing or REMOVING a pinned key needs the user's explicit sign-off before the pin is touched — a pin freezing buggy behavior is a known-bugs ledger, not a license.

Pinned surfaces

Test Surface Shape
ProgressWeb GET /api/media-items/:id/progress (percentage-only write) flat map: id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, format_group, total_characters, chapter_count, last_sync_device, last_sync_source, last_sync_timestamp
… conditional bolt-ons context_text (when stored valid); css_selector, anchor_href, char_offset, rewritten epubcfi (when the anchor verify path fires)
PUT /api/media-items/:id/progress response database.ReadingProgress JSON (25 keys incl. audio_offset_seconds, viewport/zoom/scroll/panel/reading_mode)
GET with no rows exactly {current_page, total_pages}
KOReaderProgressPush POST /api/sync/koreader/progress (immediate, 202) sync_status, books_synced, book_results[]?{sha256, book_uuid, synced}, conflicts[]?{book_uuid, conflict_type, device_progress, server_progress, resolution}, timestamp, device_updated
KOReaderProgressPull GET /api/sync/koreader/metadata/:uuid uuid, sha256?, title, authors, progress{percentage + optional character/epubcfi/koreader_xpointer/chapter/chapter_progress/page/total_pages}, annotations{highlights?, notes?, bookmarks?, deleted_highlights?, deleted_bookmarks?}, last_sync
KOReaderLibrary GET /api/sync/koreader/library library_sync[]{uuid, sha256?, title, author, content_type, percent_read, pages_remaining?, bookmark_count, last_modified}, total_books, last_sync
KOReaderBookmarksPush POST /api/sync/koreader/bookmarks sync_status, bookmarks_synced, notes_synced, highlights_synced, total_synced, timestamp
KoboInitialization GET /api/sync/kobo/:token/v1/initialization library_sync[]{ContentId, ContentType, Title, Author, PercentRead, PagesRemaining?, BookmarkCount, LastModified, EntitlementId, Revision, MimeType, FileSize, Categories?, BookhoardUUID?}, total_books, last_sync
KoboMarkupSync POST /api/sync/kobo/:token/markup Status, MarkupsSynced, BookmarksSynced, DeletedAnnotations?
KoboSyncFromServer POST /api/sync/kobo/:token/sync-from-server BooksSynced, BookmarksSent, HighlightsSent
CollectionsJSON GET /api/collections/:id (all 4 branches) envelope id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at; books = BookInfo
POST /api/collections/preview bare {items: BookInfo[]}
SeriesJSON GET /api/series/books?name=&library_id= {name, library_id, books: BookInfo[], total}
SearchJSON GET /api/media-items/search?q=&library_id= top-level array of SearchMediaItemsUnifiedRow (47 keys)
DashboardSections GET /api/dashboard/sections {sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}
MediaListBothBranches GET /api/media-items (unscoped) {data: [38-key resolved map]} (intentionally divergent from the scoped branch until the row-shape collapse batch)
GET /api/media-items?library_id=&sort= {data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}
SSRPages /bookshelf, /collections, /collections/:id, /series, /series/detail, /tags/detail 200 + stable title/substring markers

BookInfo (every card surface): media_item_id, title, author, cover_image_path, has_conflict, format_group, badge.

Changelog (additive keys per batch)

  • H0 (initial pin) — the shapes above, captured on the unmodified tree at commit b4ae423.
  • H1 (progress canonicalization) — GET /api/media-items/:id/progress Whitelisted ADDITIVE keys (fields the GetUniversalProgressRow already carries, now surfaced by the shared row serializer): audio_offset_seconds, reading_mode, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number. No key removed or renamed; the no-rows and PUT-response shapes are untouched. The web PUT binds SaveProgressRequest directly — its parse set for existing clients is IDENTICAL to the old DTO (verified against web/src reader.ts:3113-3143 and audio.ts:518-520); keys the web never sent (viewport_x/y, panel_number) merely become honor-able. All other pins unchanged.
  • H3-prep (web annotation CRUD freeze) — NEW pinned surfaces, captured pre-refactor per the pin-first doctrine: POST/PUT/DELETE /api/media-items/:id/{notes,highlights,bookmarks} — create 201 / update 200 / delete 204, response bodies are the full sqlc row models (MediaNotes 19 keys, MediaBookmarks 20 keys, MediaHighlights 27 keys — listed in contract_pins_test.go). No keys removed; nothing was pinned here before. Capturing these immediately caught a LIVE server bug: applyNoteLWW omitted UserID from its UpdateMediaNoteForSync params, so every dedup-hit note re-save (same content+position ⇒ same key) returned 500 ("update note: no rows in result set") — the LWW update could never land. Fixed in the same commit (server-side fix, freely allowed); the pins are its regression net.
  • H3 (PUT fixes routed through the sync service) — PUT /api/media-items/:id/bookmarks/:bookmarkId now parses ADDITIONAL optional request keys mirroring its create sibling: cfi_position, page_number, chapter_number, percentage, chapter_reference (all zero-value-preserving: omitted keys coalesce with the stored row inside the service, so legacy clients that send only title/notes/position keep every stored locator — verified by TestH3_BookmarkPUTPreservesLocators). The PUT lands as an id-targeted LWW edit under the row's stored dedup key (BookmarkID/NoteID mirror SaveHighlightRequest.HighlightID), so last_modified_source / last_modified_at are now maintained. Note PUT unchanged on the wire (content+position) but routed through the service the same way. Response shapes unchanged (same row models — pins green).
  • H4 (unified row completion) — /api/media-items/search rows[] whitelisted ADDITIVE keys abridged, audio_metadata: the unified search row gained the two columns it lagged (EXPLAIN plan-identical — both ride the already-scanned media_items side), and the endpoint serializes the row directly, so they emit. No key removed or renamed.
  • Origin writer attribution (post-hardening parity) — every web-API write endpoint now parses an ADDITIVE optional request key origin ("android" | "ios"; anything else stays "web") and records it as the writer: progress PUT → last_sync_source, bookmark/note/highlight create+PUT → last_modified_source (and origin_source on bookmark creates, now normalized through the same gate). Precedence on progress: a paired X-Device-Token still outranks origin. The web SPA sends no origin — its rows are unchanged. Request-parse widening documented per rule 2; response shapes untouched; locked by TestOriginWriterAttribution.

Changelog entry — multi-file audiobooks (2026-10-18)

  • GET /api/media-items/:id/chapters gained ADDITIVE response keys layout ("" for single-file books, "folder" or "zip" for multi-file books) and tracks[] (index, url, title, path, start, end, duration_seconds, size_bytes; empty for single-file books). Existing keys (media_item_id, chapters, duration_seconds, codec_profile) unchanged — no pin existed on this endpoint, so no pin widened; recorded here per rule 2's spirit.
  • New sibling endpoint GET /api/media-items/:id/audio/:trackIndex streams one track of a multi-file book (Range-capable). New surface, not a modification; gated by library visibility like ServeFile (403 on hidden libraries).
  • media_items.audio_metadata payload gained additive JSON keys layout and tracks (see schema.sql comment). Consumers parse additively; stored single-file payloads are unchanged.

2026-10-19 — A2b: metadata array/model overhaul (phase 1)

  • media_items.series_number widened INTEGER → DOUBLE PRECISION across all surfaces. JSON serializers emit the same key; values are JSON numbers, so integer-valued ordinals are byte-identical ("3") and decimal ordinals (0.5/1.5) now serialize as decimals instead of truncating. Clients reading JSON numbers are unaffected; the Android client parses either form.
  • GET /api/media-items* / detail responses gain ADDITIVE keys: subtitle (string), explicit (bool), authors ([]string, write truth — legacy author display string still emitted, now trigger-regenerated), publishers ([]string; legacy publisher likewise).
  • POST/PUT /api/media-items* accept ADDITIVE request keys subtitle, explicit, authors, publishers; legacy author/publisher singles still accepted (arrays win when both present).
  • Override keys unchanged: author/publisher override names are preserved for existing rows and now protect the arrays.

2026-10-19 — A2c: collections/playlists phase

  • GET /api/collections/:id gained ADDITIVE up_next (array of {id, title, author, cover, format, progress} — the static collection's reading queue head in position order, viewer-progress-aware; empty array for smart collections).
  • GET /api/dashboard/sections rows gained ADDITIVE is_playlist (bool — collection was imported from a sidecar ordering construct) and complete (bool — hide_finished filtered every item out of a non-empty collection; render "playlist complete").
  • PUT /api/collections/:id/items/order is a NEW endpoint (owner-only, full ordered id list → positions 1..n).
  • User collections are dashboard-eligible by default (the show_on_dashboard gate is gone from GetUserCollectionsForDashboard); hiding stays dashboard-prefs (hidden_collections) territory.
  • GET /api/media-items (unscoped resolved-map branch) and GET /api/media-items/:id gained the same ADDITIVE keys as the raw branch: subtitle, explicit, authors, publishers.
  • GET /api/media-items/search rows gained the same ADDITIVE keys plus the search pairs authors_search, publishers_search, narrators_search (the unified SELECT now carries the new columns).
  • author_filter / publisher_filter are now per-element against the arrays; a comma-joined value is OR-within-facet multi-select. The param NAMES and single-value behavior are unchanged.

2026-10-19 — Faceted navigation Batch A: new filter facets

  • GET /api/media-items/search accepts ADDITIVE REQUEST params (query string, response shapes unchanged): story_arc_filter, imprint_filter, edition_filter (scalar-fuzzy), age_rating_filter, format_group_filter, manga_type_filter, reading_direction_filter (exact), explicit_filter, abridged_filter (bool: absent = unset, "true"/"false" = bound). A comma-joined value is OR-within-facet multi-select on every new TEXT facet (the SQL splits via string_to_array). services/facet_registry.go is the Go-side source of truth; the drift-guard unit test (TestFacetRegistrySQLDrift) pins registry ↔ SQL agreement.
  • Genre model v2 changes SCANNER OUTPUT (not a wire contract): sidecar genre selection now stores only top-level shelves in genre (subgenres become tags mapped to their parent shelf; unknown-only input leaves genre empty). Existing rows are unaffected until a rescan/re-extract.
  • GET /api/media-items/facets?facet=X&library_id=... is a NEW pinned surface (registry-driven value+count listing for the filter panel): 200 → {facet, values[]: {value, display?, count}} (display present only for language codes → names); 400 for unknown facets and for bool/range facets (they render as toggles, not lists); 403 for an explicit library_id outside the requester's visible set.
  • GET /api/media-items/search (JSON) and its HTMX grid partial now share ONE param parser (handlers.ParseMediaSearchParams). Behavior notes: the HTMX branch previously dropped publisher_filter and narrator_filter silently — it now honors every facet param; and an explicit hidden-library library_id is now a 403 on BOTH branches (was: silent empty set on JSON). Response shapes are untouched; the search row pin (47 keys) is unchanged.
  • language_filter now normalizes comma elements through the ingest language map, so display names ("English") match rows stored as ISO codes ("en"). The param name is unchanged; single-code values behave identically.
  • Web: the bookshelf gains an active-filter chips bar (client-local form state rendered by Alpine; templ owns the markup) and applyURLFilters accepts the new facet params.

2026-10-19 — Scanner Phase 4: manifest-gated sidecar detection

  • No wire change: media_scan_state lives in its own table, so the raw-row list JSON cannot contain scanner state (guarded by an assertion in TestScanManifestLifecycle).
  • Scanner BEHAVIOR: size-stable books are now checked against a stored sidecar fingerprint ({"v":1,"entries":[{n,s,m-nanos,h-sha256},...]}). Stat-pair-identical → skip with zero reads; stat drift → hash reconciliation (equal hash = host repoint, stat pair refreshed, no re-extraction; differing hash = metadata re-extract + fresh manifest, metadata_overrides protected, playlist collections synced on the change path — a "(Reading Order)" entry added to an EXISTING book now creates its collection). NULL stored manifest → backfill only, no re-extraction (deploy-day rule). Multifile folder/zip books keep the Phase 3 behavior (their change path does not consume sidecar extractors yet — gating them would flip books "changed" for nothing).