New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.
Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
New users column surfaced as an On/Off dropdown in the profile form.
The form and API carry "true"/"false" strings with the theme-style
""-guard, so JSON clients that omit the field can't clobber it;
anything else is rejected. GET/PUT /api/auth/profile expose
keep_screen_awake alongside the other profile fields, and the reader
route threads the preference into its template metadata.
Integration tests pin the schema default, both values, omission
semantics, and rejection — dedicated test user, never testuser.
The combined profile handler (self-edit PUT /api/auth/profile and
admin PUT /api/auth/profile/:id) binds one struct for both paths —
and it's the admin-named one. Its twin UpdateProfileRequest had zero
references left. Delete it and rename AdminUpdateUserRequest to
ProfileUpdateRequest so the name matches who actually binds it; the
comments describing field semantics now stand on their own instead of
pointing at the deleted twin. Wire format (json/form tags) unchanged;
docs schema registration follows the rename.
docTitleOverrides now pins the label with lowercase 'and' (the naive
titleCase fallback capitalized every word); only the sidebar label was
affected — the page h1 comes from the markdown heading and was already
correct. Test pin added to TestDocTitle.
templates/csp_source_test.go scans .templ sources and fails the build on
the three authoring patterns hash-mode CSP refuses silently: native on*=
handler attributes (quote and templ brace forms), inline <script>
content outside the sanctioned theme_flash_guard.templ, and htmx's
eval-dependent bindings (hx-on*, js:-prefixed hx-vals — 'unsafe-eval' is
not granted). Verified to fire on all four violation classes via a
scratch probe file.
The dry run immediately caught a live leftover: unlinked_books.templ's
onchange="updateSelectedCount()" — CSP-refused since the hash flip.
Removed; the count already updates through the document-level delegated
change listener that x-init="setupEventDelegation()" registers.
AGENTS.md gains a "CSP discipline" ALWAYS-apply section documenting the
hash allow-list, the sanctioned-guard invariant, the replacement
patterns, and the tripwire test.
Verified: go build/vet, templates+middleware+docs tests green; /unlinked-
books loads with zero console violations (no fixture rows to exercise the
checkbox; the delegated path is unchanged code).
Follows the developer/api → API Reference carve-out: placement-only, no
file paths or URLs change. placeDoc routes user/devices/* to a new
top-level "Devices" section (icon: device) and user/admin-* to a new
top-level "Administration" section (icon: shield); User Guide keeps its
direct guides with no groups. Sidebar order: Getting Started, User
Guide, Devices, Administration, API Reference, Developer, Operations,
Contributing.
Verified live: on kobo-setup the Devices section opens with the three
device guides; on admin-guide Administration opens with both admin
guides; on user/dashboard User Guide opens as before. Tests
(TestPlaceDoc, TestBuildNavigation) updated to pin the new placement.
script-src now allow-lists the guard by sha256 of its rendered bytes
(middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the
guard is the app's only inline <script>. security_headers_test.go renders
templates.ThemeFlashGuard() and fails with the replacement hash if the
script text ever drifts.
Executing anti-XSS strict CSP surfaced three latent violations the
eval-removal sweep couldn't see (hash mode is stricter than eval mode,
because inline event handler ATTRIBUTES now count against script-src):
1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers
(modal open/close, folder-browser picker, purge button) converted to
Alpine @click/@submit component methods — openCreateLibraryModal,
hideModal, pickBrowsePath added to the admin component;
purgeArchivedItems promoted from its window global into the component
and the (window as any) exposure removed.
2. dashboard/docs: onclick handlers converted — dashboard's
event.stopPropagation() → @click.stop; docs' sidebar/search closers →
closeDocsSidebar/closeSearchResults component methods.
3. 8 onerror= image-fallback attributes (cover placeholders app-wide +
audiobook cover hide) replaced by data-fallback attributes handled by
one delegated capture-phase listener in web/src/image_fallback.ts —
works for htmx-swapped content without re-binding.
Verified: theme guard applies a saved theme on public pages under the
hash CSP; 14-page authenticated sweep clean; admin pages clean under the
promoted admin; admin-library modal open/close bindings exercised;
readers render error-free; go build/vet/middleware-test, tsc, vp
check/test (146), build:ts all green.
- Highlight color dots carried a nested-ternary + comma-sequence
expression that the CSP-safe Alpine evaluator cannot parse — the
buttons were dead. Logic moved to pickHighlightColor(color): sets
the color, saves changes for edit-mode, creates the highlight for
create-mode.
- The selection popover (and its note area) rendered above the
selection anchor with a fixed size, so on small windows or
top-of-page selections it escaped the viewport. Placement is now
window-aware: show-time clamping via estimated popover size, and a
measure-and-shift pass when the note area widens the popover (the
post-open adjustment runs well after the enter transition; mutating
during it is what breaks compositor hit-testing).
The strict CSP sweep surfaced a final class of bindings on the audio
reader and book detail pages that referenced browser globals
(Number, String, Math, window, document) — identifiers the CSP-safe
evaluator resolves only from component scope:
- audio reader: scrub input, volume slider/input, speed+skip active
styles, genre tick styles, and the default-speed select now route
through component methods (onScrubInput, setVolumeFromEvent,
applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
isSkipActive, isSpeedActive, isDefaultSpeed)
- book detail: cover-upload click and banner edit validation moved to
component methods (triggerCoverUpload, bannerEditHasText)
- admin: Refresh-to-View-Books reload moved to reloadPage()
- reader: note edit validation moved to noteEditHasText()
CSP header: frame-src 'self' blob: (foliate renders book content in
a blob: frame) and blob: on font-src/img-src (EPUB-embedded fonts
and images load as blob: URLs inside that frame). 'unsafe-eval' is
gone: script-src is 'self' plus 'unsafe-inline' for the pre-paint
theme flash guard.
Also: header openPanel promoted to the root component (one open
panel across all sidebar panels, persisted in init), with the
sidebar shell reduced to themeMode state + methods; fmtTime restored
to the audioReader component (ripped by the unexport pass — time
displays were dead on the audio reader).
- audio reader: scrub/volume/speed/genre-tick bindings converted to
component methods (onScrubInput, setVolumeFromEvent,
applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
isSkipActive/isSpeedActive) — these used Number/Math/String globals
that the CSP evaluator cannot resolve
- fmtTime restored to the audioReader component: the unexport pass
had ripped it from the Alpine.data object (time displays were dead)
- book detail: cover-upload click and banner edit validation moved to
component methods (triggerCoverUpload, bannerEditHasText)
- admin/docs/index components: init() hooks for their x-init chains
- CSP header: 'unsafe-eval' dropped — the CSP build needs no eval
- e2e spec: replace test.info().annotate (unavailable in this
Playwright version) with console logging
Standard Alpine evaluates every attribute expression through
new Function(), which a strict Content-Security-Policy cannot allow.
Swap to the official CSP-safe build (@alpinejs/csp), whose parser
evaluates expressions against registered component scope without
eval. script-src now carries only 'self' and 'unsafe-inline' (the
latter still required by the pre-paint theme flash guard).
Bindings rewritten out of the unsupported expression shapes:
- 21 multi-statement sequences across admin, audio_reader, docs,
header, index, and reader became single component methods
(init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed,
pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode,
toggleFxInvert, openPageThumbs, submitNote, clearSearch,
pickReadingTheme)
- the header sidebar's inline x-data (openPanel/themeMode IIFE)
became a registered sidebarShell component; openPanel itself moved
to the header root so every sidebar panel shares one open state
- the appearance-panel moved into the component init()
- header init() now also seeds openPanel from localStorage (or the
admin section on /admin pages)
Alpine magics (, , , ) remain available —
the CSP evaluator injects them into scope.
The Playwright suite covers the converted surfaces end to end.
Age Rating, Imprint, Scan Information, and Metadata Notes are
format-agnostic columns (the schema marks age rating 'applies to all
formats'; imprint examples span comics and books) — but they rendered
inside the Comic/Manga section, showing a manga dropdown on
audiobooks and hiding useful fields behind comic gating.
Split the editor into:
- Publication Details: Age Rating, Imprint, Scan Information,
Metadata Notes — always visible for every media type (empty for
non-comics sources: no standard metadata carries these outside
ComicInfo.xml; editable and persistent for all).
- Comic/Manga (gated by ShowComicMetadata, unchanged): Manga Type,
Reading Direction, Story Arc, Black & White.
Pure presentation move — identical form field names, no handler or
schema changes.
The metadata editor's Comic/Manga section (Manga Type, Reading
Direction, Age Rating, Story Arc, Imprint, Scan Info, Metadata
Notes, B&W) rendered for every item — an audiobook showed a manga
dropdown, which is nonsense.
MediaDetail now carries LibraryTypeName (resolved via
GetLibraryType in the /media/:uuid handler) and a
ShowComicMetadata() gate: comics and manga libraries always show the
section; other media only when the scanner recorded comic-style
values (fixed-layout manga-style EPUBs get those scanner defaults
deliberately — when present they stay editable).
Audiobookshelf-style exports join genres into one comma-string
(["Epic, Action & Adventure, Military, Sword & Sorcery"]). The
smart-genre picker already split candidates for the genre column,
but the tags assembly used the raw unsplit array — so the whole
string landed as ONE element of media_items.tags and rendered as a
single tag everywhere.
Use pickSmartGenre's split candidates for tags too, exactly as the
function's own comment documents ('split parts included stay in
tags'). Commas inside a single subject heading remain legal within
one tag (EPUB dc:subject path unchanged).
Two regressions from the cleanup cycle, caught in E2E:
1. The unexport pass stripped loadTheme, loadUserTheme, and
updateThemeIndicators from the Alpine.data("theme") registration —
the member-removal pass could not tell export-block lines from
Alpine component object lines. Theme dropdowns on docs and
non-app pages stopped reflecting the active theme. Members
restored (functions were kept, only their scope registration was
lost).
2. The new Content-Security-Policy shipped script-src without
'unsafe-eval'. Alpine evaluates every @click / x-init / x-show
expression through new Function(), so the CSP silently disabled
ALL Alpine interactivity app-wide: sidebar categories stuck open,
modals rendering unhidden and unresponsive, dropdowns inert.
script-src now includes 'unsafe-eval' with a comment explaining
the Alpine requirement.
Drops highlight.js, lunr, autoprefixer, and @types/markdown-it —
all verified unreferenced (markdown-it ships its own types). Adds
knip.json configured for the templ/htmx architecture: main.ts and
reader/reader.ts entries, vitest tests as entry points, and an
ignore list for the ten modules whose exports are bound by templ
Alpine attributes — string bindings that static analysis cannot see.
Lifecycle (internal/app + main.go):
- App.OnShutdown hooks run after the HTTP server drains, in
registration order: Worker.Shutdown (background jobs now drain
instead of dying on SIGTERM), DeviceAuthMiddleware.Stop (releases
the rate-limiter ticker), Handler.CloseScanner (fsnotify watcher
and scan log files)
Security headers:
- SecurityHeadersMiddleware (survivor of middleware/security.go,
renamed security_headers.go) is now on the echo stack: nosniff,
frame-deny, HSTS, CSP (unsafe-inline required by the sanctioned
theme flash guard + Alpine), referrer and permissions policy
Scanner settings cache:
- settings saves (UpdateScanSettings + ApplySetting for the three
scan keys) now invalidate MediaScanner's 30s settings cache via
SystemSettingsHandler.SetScanSettingsChanged, so changes apply on
the next poll instead of after the TTL
- MediaScanner.Close closes the scan logger, fixing a file-descriptor
leak on every per-item rescan (fresh scanners never closed logs)
Client fix:
- custom-section-builder's add/remove-selection buttons used inline
onclick attributes referencing module-scoped functions, which are
not global in a bundled ES module: the buttons threw ReferenceError.
Functions restored and exposed on window via declare global
Also fixes a pre-existing vet error (saveCoverImage copied
echo.Context by value, mutex included) and drops the per-key
SettingsCache.InvalidateKey variant left callerless by the
full-flush invalidation wiring.
Repo-wide gofmt sweep: const-block alignment, doc-comment spacing,
statement-after-brace fix in media.go, trailing blank lines. No
semantic changes — whitespace-insensitive diff is empty for every
file in this commit.
The clamp put deep-linked highlights flush against the sidebar's bottom
edge. Centering is simpler AND handles the max-scroll case for free:
scrollTop carries the item's center to the scrollport center, and the
browser clamps to the scroll extent — items near the nav's top/bottom
land as centered as the content allows (scroll pinned fully up/down),
which is the requested fallback.
Deep-linking into a low doc (e.g. ratings/get_ratings from the overview
page) opened the right menus but sat the highlight below the sidebar's
visible scroll area. scrollSidebarToActive() runs at docs Alpine init:
finds the link for the current path, and if it's outside the sidebar's
scrollport, moves ONLY the sidebar's own scrollTop to clamp it into the
nearest edge — never the page scroll. Skips the off-canvas mobile
drawer. (Same behavior as Docusaurus/MkDocs on deep links.)
Live: x-init wired on docs pages, function in the shipped bundle,
make test green, 146 vitests.
Root cause of 'clicked from overview, bold but menus closed': cross-page
markdown links point at area/file.md; the handler happily loads .md
URLs, but the sidebar keys on the URL path — contains-URL expansion and
the active-link styling compare against extension-less nav URLs, so a
.md path rendered the page with its menu closed and the current item
unhighlighted. (My curl checks had used clean URLs, which is why it
worked in testing but not from the overview page.)
Fix: ShowDocumentation 301-redirects .md paths to the clean canonical
form (query preserved; browsers re-attach fragments), and
renderDocPage normalizes the path for nav expansion/grouping as a belt-
and-braces. Live-verified: .../get_ratings.md -> 301 ->
clean page with both containing menus open and the get_ratings link
carrying the active class.
Simplification per spec: the menu tracks state through the URL alone.
Land on /docs: fully closed, headers only (an explicit home rule now
overrides the contains-URL rule, since the root overview doc lives in
Getting Started and would otherwise auto-open it). Land on any doc page:
its containing section + group render open at first paint, current link
bold — no JS, no jump, arrows consistent. Navigate elsewhere: old
closes, new opens.
Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write +
collect/persist helpers in web/src/docs.ts, data-key attributes in the
sidebar templ, and the first-section-open fallback (docs home used to
force-open the first section — spec'd as closed).
toggleCollapsible remains for in-page peeking; purely ephemeral.
The [data-collapsed=true] CSS from the previous round stays — it is what
makes the server-rendered state actually hide sections at paint (the
original bug was that this CSS never existed).
Tests: TestBuildNavigationNothingCurrent now asserts zero sections open
on unmatched paths; TestBuildNavigation asserts Getting Started closed
on a device page (no site-wide pin). make test green; 146 vitests; live
verified: home 0 expanded, doc pages exactly 2 (section+group).
Three problems, one root cause each:
1. Sidebar jump: the server rendered its own default (current section
open, rest collapsed) and restoreDocsNav corrected it after paint —
open-then-close. Fix: persistence moved server-side. toggleCollapsible
now writes a docs_nav cookie (strictly-necessary UI state, no
identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav
before render, so first paint IS the visitor's state. The post-paint
restore is deleted.
2. Arrows out of sync / double-click: two visibility mechanisms
disagreed. There was NO data-collapsed CSS at all — collapsed sections
were only ever hidden by JS after paint, and the arrow rotation came
from a different (server) state. Now: style.css hides on
[data-collapsed=true] (single source of truth, server-rendered),
applyCollapsed sets only the attribute, arrows rotate from the same
merged value. Live check: 37/37 collapsibles arrow-consistent.
3. api-reference.md links: all 20+ quick links were directory-style
(authentication/ etc.) that 404 on the docs site, plus stale targets
(books/, ../collections-api.md, websocket 'coming soon'). Rewritten to
real per-file links — all 9 unique targets return 200 live. Same rot
fixed in admin/hash-conflicts.md, development.md, android-app.md,
create_media_item.md, koreader-protocol.md; docs/index.md redirected
from the deleted collections-api monolith (stale 530-line duplicate of
the gated api/collections docs — deleted, per the WO2 doctrine).
New gate: TestDocsRelativeLinks walks docs/developer/** and fails make
test on any relative link that is directory-style or unresolvable —
overview links can never silently rot again.
Theme flash (admin pages, docs, stats, progress — anywhere SSR'd):
the JWT middlewares built the context user WITHOUT the Theme column,
so every page rendering theme-{user.Theme} from c.Get("user") painted
an empty class (:root tokyo-night default) and only settled on the real
theme after initializeTheme -> loadUserTheme round-tripped the server.
Both middlewares now carry liveUser.Theme into the context user;
verified live: /admin /stats /progress /docs /docs-deep /dashboard
/collections all SSR theme-hackerman.
Defense in depth for the same bug: loadTheme() no longer re-applies
localStorage over a server-rendered body theme (applyTheme records even
the default, so any signed-out visit baked tokyo-night into localStorage
and every signed-in page flipped to it post-paint). Public auth/landing
bodies dropped their hardcoded theme classes so ThemeFlashGuard's
pre-paint html class actually governs them; docs theme <select> now
syncs from the body class and re-syncs after the profile fetch.
Sidebar state (the unpleasant part): the app sidebar's openPanel was
reinitialized from the pathname on every load; it now restores from
localStorage ('sidebar-open-panel') with the pathname default as
fallback and -persists every toggle. Docs sidebar sections/groups
carry data-key attributes and toggleCollapsible persists collapsed
state per key ('docs-nav-state'); restoreDocsNav() applies it at init —
server-rendered defaults still govern first visit.
make test green; rendering ownership OK (no new inline scripts); 146
vitests pass; live-verified SSR classes on 7 pages.
Three follow-ups from the smoke run, all additive:
1. Status asserts: bruno_gen.go parses each doc's documented success
status (## Response (2NN), 'Success is **201**', '**204 No Content**')
and emits a runtime tests script (expect(res.status).to.eql(N)) on
148 of 191 mirrors. GETs default to 200 (uniform across this API);
mutating verbs stay assert-less unless documented — no guessed
asserts. Verified live: asserts execute and fail loudly (deliberate
999 probe), pass on real responses.
2. OpenAPI: the same -update run now also writes
docs/developer/openapi.yaml (141 paths, tags from docs areas,
security schemes per auth label, request examples literal, no
invented schemas — the docs site remains the shape reference).
Verify mode fails when the file is missing.
3. Scenarios: 7 safe negative scenarios rebuilt (auth registration
matrix 409/403/400, invalid-uuid 400, unknown 404 x2, invalid range
400) — all no-mutation, repeatable, run via bru: 7/7 tests pass.
These replace a fraction of the deleted broken suite; mutations-
heavy scenarios stay out until a design for setup chains exists.
created_at RFC3339 deliberately NOT done — it is the one item that
would break things (Android parses current formats; plan reserves it
as a compat decision).bru run of mutating mirrors also proven to touch
live data (a 'To Read' collection was created and deleted in testing);
only id-less GET mirrors and negative scenarios are safe for blanket
runs.
make test green; generation idempotent (byte-identical re-runs);
no stray smoke rows in users.
Curated smoke run of the generated collection against the live dev
server (37/37 after fixes): registration matrix (409 duplicate email,
403 admin-creation-with-admins-present, 400 weak password), non-admin
403s, device-token/opds 401s, collection CRUD incl. the documented 201
update quirk and 204 delete, stats routes, scanner, jobs, malformed-uuid
400s. Testuser untouched (password test uses a guaranteed-wrong current
password -> 401); the smoke user was admin-deleted in cleanup and its
token verified dead.
Correction the smoke run forced: the batch-8 'password_hash leak' was a
false alarm — my registry pin ([]database.Users) never matched the
handler, which returns []database.ListUsersRow (sqlc join with
device_count). Live response confirmed: id/email/username/theme/names/
role/max_devices/created_at/updated_at/device_count — NO hash. Doc
example, field table, and registry pin all corrected; leak note
replaced with the live-verified statement.
Also verified via the real Bruno CLI: 9 safe generated requests
(4 shown passing incl. stats/collections/devices; password-rules
public) execute against the server with collection-level auth.
Full-collection bru run deliberately not attempted (mutations in
static bodies would touch live data).
The generator moves into the Go toolchain per repo preference:
internal/docs/bruno_gen.go emits the OpenCollection mirrors (same output
contract — gate-verified example-request bodies, env-var params,
device-token headers, login/register/refresh token-capture scripts) and
TestBrunoMirrorsDocs grows a -update flag: verification by default,
regeneration on demand:
go test ./internal/docs -run TestBrunoMirrorsDocs -update
Generator and checker are now the same code — they cannot drift apart.
scripts/gen_bruno_from_docs.py deleted; opencollection.yml README points
at the go-test command.
Also fixed vs the Python pass: mirror placement follows the docs tree
folder (RelPath), not the area frontmatter (several docs — highlights,
notes, ratings, users, progress — carry a different area); docs blocks
emit the collection's mapping form (content + type), not a bare scalar;
JSON body sanity is checked at generation time.
Idempotent: back-to-back -update runs are byte-identical.
make test green (9 pkgs).
WO4. The old collection was the drifted artifact AGENTS.md warned about:
bodies pre-truncated at their first {{var}}, routes renamed server-side
but not here (/api/notes/:media_id, /api/book-matching/search,
/api/collections/:id/rules/test), an orphan universal-progress suite for
routes that no longer exist.
scripts/gen_bruno_from_docs.py emits one OpenCollection request per
frontmatter'd endpoint doc (191 requests): gate-verified example-request
JSON as body, {{env}} params per the environment file, device-token
routes get an explicit Bearer {{device_token}} header, login/register/
refresh keep the token-capture after-response scripts that chain the
collection. Old 1:1 mirrors deleted (128); hand-authored NewDevDBSetup,
environments, and edge-case suites kept. 30 scenario files with
truncated bodies (the documented corruption) deleted — they could not
run.
internal/docs/drift_test.go gains TestBrunoMirrorsDocs: every
frontmatter'd doc must have a Bruno request with matching method+URL
shape — the collection can never silently drift from the docs again.
opencollection.yml README block replaced with the real run instructions.
make test green (9 pkgs, parity test included in internal/docs).
Stats docs written handler/service-first (summary/this-week/book-stats
new; library-usage frontmatter'd — its body was already accurate).
Registry: 4 entries on services.UserSummaryStats/WeekSummaryStats/
LibraryUsageStats/BookStats. Family-lens note: library-usage 403s an
explicit library_id outside the visible set (431fec0 rule).
UNVERIFIED sweep (7 -> 0), each resolved against the handler:
- device-usage takes NO date params; popular-books takes only ?limit
(the 'standard analytics date-range params' claim was wrong)
- link_book media_item_id: no validate tag but service-always-written
- push_stats book-ref field set documented (uuid/sha256/title/authors)
- filter_count: confirmed ParseMediaSearchParams machinery + 400 on bad
filter + 403 visibility
- facets values element shape pinned: {value, display?, count}
- get_config: dynamic key set IS the contract (reworded, no marker)
make test 9/9.
All remaining endpoint docs frontmatter'd: media-item bulk ops + audio
surface, collections book-listing/preview, koreader rating/review/stats
plugin protocol, hash-conflicts JSON list.
Scope call (Claude): resolve_hash_conflict.md DELETED — the resolve POST
renders an htmx fragment (renderResolved), a web-admin flow under rule 3;
only the GET list is a JSON API. update_media_review skipped by Beam
(correct): ratings/upsert_media_review.md already covers the PUT.
Registry: 16 entries. koreader rating/review/stats all bind
koreaderBookRef (query or body) — odd=half-star rating scale; audio track
endpoint serves raw bytes (ServeContent, Range) — response nil. Fixed
drift: deleted_annotations doc's secondary restore/purge fences
unlabeled (gate validates labeled blocks against the ONE frontmatter
route); chapters example labeled. UNVERIFIED: 1 (koreader book-ref
fields, push_stats). make test 9/9.
WO3 sync internals resolved: the book-matching docs ARE the sync surface
(books/query, link-book, bulk/auto-link, unlinked-books, suggestions,
file-alias CRUD) + collections rule/test/bulk-assign/shelf-mapping
surface.
Dedupe resolved (Claude call): search_books.md declared
GET /api/books/match but its BODY documents POST /api/sync/books/query —
both routes exist and are distinct (query-param device protocol vs JSON
body plugin protocol). Repointed search_books.md frontmatter to the POST
route; get_book_matches.md keeps the GET.
Claude registry: 15 entries (one pre-existing link-book entry deduped).
Drift fixed by gate: auto-link request {confidence_threshold, limit} not
{device_id, threshold}; bulk-link link objects {unlinked_book_id,
media_item_id, confidence_score} not {device_id, book_id, ...};
responses {results,total,successful,failed}/{auto_linked,results}; added
missing example-responses (bare delete, remove-rule is_smart-after
semantics, bulk-assign per-book results). make test 9/9.
Highlights + notes CRUD (media-item scoped, jwt), progress delete,
reading-stats analytics, and the OPDS trio frontmatter'd. OPDS docs are
the first XML entries: example-response intentionally omitted (Atom
responses), gate contract honored via nil-Response registry pins.
Gate fix: group .Use() middleware now parsed (opds.Use(DeviceAuth...
Authenticate) previously resolved to auth 'none'). Registry: 13 entries.
Drift fixed by gate: reading-stats key names (total_reading_time_minutes,
most_active_day_of_week, daily_reading_minutes — draft invented
reading_time_hours/books_completed); notes/highlights list endpoints
return BARE arrays (no {notes:[]}/{highlights:[]} wrapper); create-note
request has no percentage_location/epubcfi_location. New
/tmp/merge_arrays.py handles array-example merges.
make test 9/9.
14 endpoints backfilled: devices pending/approve/reject; users
password/profile/profile-delete self-service (admin variants in prose);
admin list_users + update_user_max_devices; progress history; book
matching; new password-rules doc.
Gate fix: authLabel missed JWT middleware written inline as
createJWTMiddleware(cfg) (uppercase 'JWT') — reported auth 'none' for
/api/auth self-service routes and would have for any wrapper-inlined
group. Labeled 'JWTMiddleware' + fallthrough 'jwt' case.
Claude registry: 12 entries. Drift fixed by gate: list_users on-device —
handler returns raw []database.Users (bare array, full model incl.
password_hash — real leak the API emits; pinned + documented; earlier
draft had invented {users,total,limit,offset} wrapper). max-devices
response is {message} only. books/match is GET /api/books/match
{matches, action}. progress history sessions = ReadingHistory rows
(pgtype fields). make test 9/9.
Scanner surface frontmatter'd (scan/start/stop/cancel/status/active/
active-summary + watch start/stop/status) with provenance comments;
active-summary on a non-admin group.
Claude registry: 10 entries. Fixed handler drift Beam missed: scan_library
request has no "recursive"; scanner/start binds folder_paths (not
invented interval_seconds); scanner/stop binds no body; response is
{message, job_id, status} with 409 coalescing variant
{error, active_job_id}.
Gate fix in drift_test seed: registerScannerRoutes group seeds used bare
"jwt"/"admin" tokens invisible to authLabel; and silently passed before
only because the seeded set was unchecked. Seeds now 'jwtMiddleware'/
'AdminMiddleware' markers. Also: cleanup of a stale-merge-script residue
in collections/update_rule.md example (extra null keys). make test 9/9.
Kobo emulation + KOReader calibre-compatible surface backfilled with
device-token auth; collections reorder + update_rule created.
Claude registry: 12 entries. kobo/koreader emulation endpoints all bind
arbitrary protocol POST bodies (markup/bookmark/gettests/sync-from-server,
koreader progress/bookmarks) — registered as AnyJSONObject, not bodyless as
first drafted. Initialization returns the library sync payload, not the init
response.
Contract correction in update_rule.md: the route is SetRuleEnabled ({enabled}
required -> {collection_id, is_smart}); Beam's draft invented rule-field
updates. Reordered response/status: {item_ids} -> {status: reordered}.
make test 9/9.
Beam backfilled 11 library docs (CRUD, folders, visibility, browse, stats,
media-items) and re-attached frontmatter to create_collection.md — which had
silently lost its pilot version in cc893f75: the negative-test
'git checkout --' restored the OLD file because the pilot was never staged,
and a frontmatter-less doc is invisible to the gate. Claude restored the full
corrected pilot content (priority higher-wins, time.String() note, 409).
Claude registry: 11 entries + one inverted catch — DELETE /:id/folders DOES
bind {folder_path} (handler reuses AddLibraryFolderRequest); registry first
draft said bodyless. GET /:id/visibility returns joined
GetUserVisibleLibrariesRow (type_name etc.), not bare Libraries.
Gate-driven merges fixed create/get library response shapes (sqlc row keys:
created_by_admin_id, library_type_id, updated_at; dropped invented type/
folders/is_visible). Fixed invented request fields (user_id in
set-visibility; library_type_id in update). make test 9/9.
Beam backfilled list/get/create/update/delete media-item, search, ratings
CRUD, progress GET/PUT. Beam correctly identified create/update/delete as
jwt+admin (admin group in the router — verified).
Claude registry: 12 entries. Notable pins:
- get_media_item: hand-built ~44-key map pinned verbatim from the handler
- create/update return the full database.MediaItems row (74 columns) —
examples merged up to the real shape via gate-driven merge script
- search returns []SearchMediaItemsRow (sqlc row — Real type pinned)
- rating scale is 1-10 int (odd = half-star); GET returns {rating: null} when
absent; update reuses the create handler (201)
- progress GET: 25 base keys + 4 CONDITIONAL position-authority keys
(css_selector/anchor_href/char_offset/context_text — added by the handler
for convertible formats with a valid CFI); PUT progress binds
sync.SaveProgressRequest and returns database.ReadingProgress
- Beam's examples were missing entirely on 4 files (list/search/progress
GET/PUT) — relabeled existing fences or inserted contract-shaped blocks
Gate-driven merge scripts (object + array variants) brought all examples to
the exact handler key sets. make test 9/9.
Beam backfilled auth core (login/register/refresh/logout/profile) and devices
core (list/update/delete/shelf CRUD/sidecar/register flow) with frontmatter,
provenance, and labeled examples. All claimed shapes verified against
handlers: AuthResponse {access_token, refresh_token omitempty, token_type,
expires_in, user}, update device {device_updated, device}, shelf wrappers,
DeviceAuthStatusResponse.
Claude registry verification: 15 entries pinned by type (LoginRequest,
RegisterRequest, AuthResponse, RefreshTokenRequest/Response, UserProfile,
DeviceListResponse, DeviceUpdateRequest, AddToShelfRequest, SidecarConfig,
DeviceRegistrationRequest/Response, DeviceAuthStatusRequest/Response).
Removed revoke_device.md — name-only duplicate of delete_device.md (same
DELETE /api/devices/:id route; was the last audit-orphan doc).
Gate catches this round (subtle Go serialization facts, now pinned in docs):
- uuid.UUID arrays never serialize as omitted: register/status response
ALWAYS carries device_id (all-zeros while pending) despite omitempty
- update device request binds device_name, not name
- register response includes device_id alongside registration_id
- list devices response is a {devices, total} wrapper
Beam added frontmatter + provenance + labeled examples to the 7 conflict and
6 queue docs, fixing real field drift on the way (resolve response keys,
bulk result shape, dismiss-all deleted count, queue stats counts).
Claude registry verification found TWO Beam catches of Claude's own draft:
- queue routes use :device_id / :item_id params (registry first draft said :id)
- resolve_conflict DOES take a body (ConflictResolutionRequest) — registry
first draft said nil
Both fixed; gate then surfaced one real Beam defect: example-request used a
nonexistent "resolution" field in resolve + bulk-resolve (real contract:
winner=<source|manual> with required manual_data for manual; strategy=
most_recent|highest_progress|manual). Fixed in the docs.
Pinned by type: ConflictListResponse, ConflictDetailResponse,
ConflictResolveResponse, BulkResolveRequest/Response, QueueStatsResponse,
ConflictResolutionRequest. dismiss-all deleted is an int count.
Beam drafted 15 frontmatter'd docs (saved-filters CRUD, dashboard
sections/preferences/restore, system config/settings, series list + books);
Claude verified shapes against handlers and pinned the registry:
- saved-filters: SavedFilterResponse pinned by type (RFC3339 timestamps here,
unlike most of the API); create/update share CreateSavedFilterRequest
(resource_type oneof media-items|collections|devices)
- dashboard: preferences GET pin {hidden_collections, collection_order,
items_per_section}; PUT returns full UserDashboardPreferences row; sections
wrapper {sections}
- system: GET+PUT /api/system/config are admin-defined key/value maps — new
AnyJSONObject sentinel (gate shape-checks without key comparison; request
side too). UpdateSystemConfiguration returns {status, message} — verified,
not guessed. GET /api/system/settings returns []SettingEntry.
- series: {series[{name, library_id, book_count, total_in_series,
cover_paths, last_entry_at}], total, limit, offset}; books by
?name=&library_id= with family-lens 403
Dedupe: deleted dashboard.md, system/config.md, system/settings.md — each
covered the same endpoints as Beam's per-endpoint files (content merged by
the gate-verified shapes; old prose recoverable from git).
Beam clarifications confirmed: scanner routes are WO5 (docs exist);
progress/books-match are WO5.
DeviceUsageResponse / PopularBooksResponse / services.JobResult pinned by
type; jobs create uses an inline anonymous request struct (RawMessage pin).
Two UNVERIFIED markers recorded (analytics date-range params, facet value
element shape).
New frontmatter'd docs:
- download (ServeFile — raw bytes, family-lens enforced; corrects the old
download doc's claim that no download route is registered: ServeFile IS
routed, DownloadBook is the dead code)
- set_audio_speed (speed range 0.25-4.0, NaN/Inf rejected)
- rescan (abridged example over the full 70+-column row; write-after-success
guarantee noted)
- purge-archived (destructive, admin), unarchive (204), merge (SHA-256
precondition, per-user collisions keep active copy)
- facets (two modes: facet+values, field autocomplete counts)
- filter_count (family lens)
Registry: RawMessage pins for map/abridged responses; Schema{} for non-JSON
body routes (download = raw bytes; unarchive = 204). Audit now:
missing docs down to ~55 (from 112), 0 orphans.
New frontmatter'd docs with real shapes (sqlc models pinned directly):
- bookmarks: get/create/update/delete (sync-aware rows: dedup_key,
last_modified_*, tombstones; delete is 204)
- review: upsert (empty string DELETES the review) / get (200 with
{review:null} when absent) / delete
- get single note + highlight (full sqlc row shapes)
- annotation history: restore (POST, kind via query or body) and purge
(DELETE, kind via required query; permanent)
Gate hardening forced by this batch:
- required-field walk now guards non-struct Request types (RawMessage pins)
- update_media_bookmark needed a full example block (no cross-reference
shortcuts — the gate checks every doc standalone)
New frontmatter'd docs with handler-compatible shapes:
- get_library_types.md (public; []LibraryTypes)
- list_libraries.md (admin; {data: ListLibrariesRow})
- get_library_folders.md (admin; []LibraryFolders)
- list/get stats/resolve/delete processing issues (admin; family-lens 403s
documented)
- scan_library.md (202 + 409 coalescing with active_job_id)
- get/update scan-settings (ScanSettingsResponse)
Gate improvements forced by this batch:
- Array responses: registered slice -> doc example must be a JSON array; key
set compared against the element type
- RawMessage pins may now be arrays
- update scan-settings uses a RawMessage pin: handler always sets Message but
the struct's omitempty would reject correct docs via zero-value marshal
Registry: sqlc models pinned directly (LibraryTypes, LibraryFolders,
ListLibrariesRow — json tags verified in the generated code).
Every API doc that declares frontmatter (method/path/auth/area) is now
checked by a unit test against three sources of truth:
1. Route registration: internal/router/*.go is parsed the way echo resolves
it (group prefixes concatenate; scanner.go's parameterized groups seeded).
Doc paths that no longer exist fail. (This is the check that would have
caught the /api/api/admin/hash-conflicts split.)
2. Auth: frontmatter auth label must match the middleware chain
(none|jwt|jwt+admin|device-token|optional-jwt).
3. Shapes: internal/docs/schema.go registry — example-request decoded with
unknown fields DISALLOWED (typo guard), required-by-validate fields must
appear (staleness guard), example-response top-level key set compared
both directions against the marshaled handler type (or a RawMessage pin
for hand-built map responses).
Five collections docs are the pilots with real handler shapes; notable
corrections they pin: update returns 201 (not 200), list excludes smart
collections unless include_auto=true, rule priority is 1-10 higher-wins
(docs claimed 1=highest), created_at is Go time.String() not RFC3339,
hide_finished/show_up_next toggles documented. CollectionResponse hoisted
out of GetCollections/GetBookCollections as CollectionSummary /
BookCollectionSummary so the registry can pin real types.
The gate fails on missing registry entries by design — that is the
maintenance contract. Known pre-existing (not this commit): vet copy-lock
warnings in media.go.
The admin group already sits under the /api prefix, so registering
"/api/admin/hash-conflicts" produced /api/api/admin/hash-conflicts while the
hash-conflicts page's htmx buttons posted to /api/admin/hash-conflicts/:id/
resolve — a 404 that htmx swallows silently (no swap, no error), so duplicate-
content resolution has been broken since the feature shipped (03cb4c78).
Verified live: sane path 401 anon -> 200 with admin token; doubled path 404.
Caller audit found no other consumers (Android app, koplugin, web reader,
tests, Bruno: no references). Docs already describe the sane path and are now
correct without edits.
Docs pages no longer embed the app Header (whose hx-post login form silently
degraded to a native GET, leaking credentials into the URL — this page never
loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous
visitors get a plain /login link. Optional-auth middleware (hand-rolled —
echojwt's ErrorHandler return ends the chain, producing empty 200s) populates
the user for signed-in visitors.
Navigation rebuilt from the docs directory tree: 6 sections, subcategory
groups, server-rendered expansion and active states. Search results are a
templ partial (/docs/search/partial); code highlighting is server-side chroma
(previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars
(prose-invert painted unreadable text under light themes). /docs renders the
index directly; standalone 404 without the tailwind CDN.
Tests: navigation routing/expansion (internal/docs), render pins asserting no
login form/password field/app sidebar ever return on docs pages.
lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts
baseline 5 -> 1.
- A progress sync conflict used to be flagged only inside the progress
card, far down a long page. An unresolved conflict now also renders
a full-width warning strip at the very top (under the app bar) that
opens the same resolution modal on click. Rendered from the same
ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
cycle total → book left → chapter left (§10's toggle was
undiscoverable).
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
audio_reader.templ's <head> was the only page head without the
favicon link — the tab icon vanished the moment you opened a
listener. Standard <link rel=icon> added, matching every sibling
page.
The Player settings popover gains a 'Default playback speed' row
beside autoplay — a compact rate select, always visible (the Speed ▾
footer hides when current == default, which made the setting
undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts
dialog splits its pointer section into MOUSE (click/drag/shift/wheel)
and TOUCH (drag/pull-away) — the bar has two scrub systems and the
sections now say so.
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
preference, NOT a measurement and NOT a reading_progress column) +
users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
ships playback_speed + default_playback_speed (zero extra round
trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
client SPEEDS list stays the real gate; library visibility gates
writes)
- the user default rides the profile path like theme/timezone:
profile form gains a 'Default Playback Speed' select (server-side
SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
audio_speed demotes to a first-paint cache); setSpeed PUTs
fire-and-forget with a failure toast; the Speed dropdown gains a
'(default)' row marker and a 'Make N× my default' action with a
success toast
ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).
Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
Alpine :style string bindings REPLACE the element's style attribute —
the rail's static background/border/z-index were wiped the moment the
binding evaluated, leaving bare text over the transports. Fold all
declarations into the single :style binding.
Clicking the end time cycles the three display modes, persisted per
device in localStorage (audio_time_display). Pure helpers (mode list,
normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback
to book remaining) + vitest; aria-label announces the current mode and
the next. Remaining values track the scrub cursor mid-drag. 140 unit
tests; 15 Playwright checks on KJV + Gardens.
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.
Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).
Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
The scrubber becomes a layered bar: the native <input type=range> stays
on top for a11y (focus, arrow keys, aria — its track goes transparent
via static page CSS) while the layers beneath render the state:
- Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px
apart) so an 1189-chapter 86-hour book gets ~100 readable marks
instead of a solid mass; the current chapter's tick is tinted.
- Buffered span: the ACTIVE track's buffered-ahead end mapped into the
global bar (buffers are per-track and never span the whole bar).
bufferedAheadEnd reads the range containing the playhead — a huge
m4b's moov index at the file tail is its own buffered range and must
not read as 'the whole book is buffered'. Rides progress + the
events that demonstrably fire for Range-served files that go
network-idle while paused (timeupdate/canplay/loadedmetadata/playing).
- Wheel over the bar: ±5s nudge, Shift = ±60s.
- Hover tooltip: time + chapter title under the pointer.
- Precision rail: while dragging, a ±5-minute window floats below the
bar (fixed anchor; release and re-grab to travel far) and the drag
maps at minutes-per-pixel instead of hours-per-pixel — the whole
point for 80h+ books. Click snaps to a chapter line within ~5s
(snapToChapter).
Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel
+ snaps, captured moves remap through the rail, release seeks. The
native drag default is prevented so the two mappings never fight;
keyboard arrows keep their native step and seek via change.
Time labels get reserved width — the bar no longer resizes when the
elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail
drag check measuring a 25% mapping drift).
Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap,
tick density, per-track buffer→global span, rail window/mapping/ticks.
Unit: 16 new tests including both book shapes (86h/1189ch and
26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of
the Moon (single-file m4b); zip books share the folder manifest shape
(no zip fixture exists in the dev library). Probe user deleted.
Header gear (beside the keyboard icon) opens a 'Player settings'
popover — one option row today, structured for more: 'Autoplay on
open', a role=switch toggle persisted to localStorage audio_autoplay
('1'/'0', default off) via the normalizeAutoplay one-source helper.
maybeAutoplay() runs strictly AFTER restorePosition resolves: the
?loc=audio:<seconds> deep link keeps winning on position; the setting
only decides whether playback starts. A browser autoplay-policy
rejection (or any play() failure) is swallowed silently — the user
just taps play; the unhandled-rejection trap in init would otherwise
shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so
Esc closes the panel with the other surfaces.
Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default
off stays paused, toggle + persistence, reload autoplays with no
gesture, deep link lands at 600s global while autoplay starts, off
stays paused, and a stubbed NotAllowedError proves the silent
swallow; probe user created and deleted.
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.
audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.
End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.
sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.
Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).
audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.
Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).
SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.
Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.
normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.
Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
restorePosition() swaps the <audio> element's src (manifest track for
multi-file books, whole file for single-file, plain URL on offline
start), and loading new media resets playbackRate to the element
default. init() had applied the persisted speed before any src
existed, so after the swap every load actually played at 1x while the
Speed pill still showed the stored value — pick 1.5x, reload, and
audio was back at 1x.
The seekTo() src-swap path already re-applied the rate; the initial
load paths now do the same (manifest branch + offline-catch branch).
Surfaced by the phase-1 speed-list browser check: button label and
audio.playbackRate disagreed after reload.
Codify the standing rule at the top of the Tests section: the dev DB is
the test environment, and the invariant every test must uphold is the
testuser experience — its settings, scans, and reading data must be
exactly as the suite found them. The helpers exist to enforce this;
when a test needs protection the helpers don't provide, expand the
helpers rather than hand-rolling.
New known-gotchas from the 2026-10-21 incident cluster:
7. scanner re-extraction is write-after-success — audio_metadata is
guarded on both writers; new scan-owned column writes need the same
guard.
8. templ double-escapes style attribute values (sanitizer + EscapeString)
— emit quote-free CSS via unquoted url() tokens through SafeCSS.
9. vp test runs the vite config — build-hook plugins fire in the test
context too; outDir-mutating plugins need an "am I a build?" guard.
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.
setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
Three tests in scanner_audio_guard_test.go pin the routes the wipes
traveled; each drives a real scan/rescan/edit over ffmpeg-rendered
fixtures and asserts the payload survives with duration and manifest
intact:
- TestRescanMediaItemPreservesAudioMetadata: folder book through the
focused payload writer.
- TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident
class verbatim — single-file m4b (no layout key) routes through the
generic updater; fixture rendered with ffmpeg AAC/ipod
(writeTestTrackM4B).
- TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's
full-row save through PUT /api/media-items/:id.
Verified to bite: with the handler fix stashed, the editor test fails
with "metadata edit must never NULL audio_metadata"; with it, green.
PUT /api/media-items/:id (the metadata editor) writes the FULL row via
UpdateMediaItem, whose query sets audio_metadata unconditionally — and
audio_metadata has no editor field, so the params literal left it nil
and every save NULLed the payload. A plain metadata edit (genre tweak,
title rename) dropped any audiobook back to "1 second long" with no
track manifest: the same incident class as the scanner wipe, sitting
on the most-used admin path in the app.
Carry existing.AudioMetadata through the write. Write-after-success:
only the audio chain (probe-backed extraction /
UpdateMediaItemAudioBookPayload) ever replaces that column.
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).
Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
extraction carries no audio payload, keep existing.AudioMetadata.
Single-file m4b books route here, and a failed/absent extraction
leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
tracks, no chapters, no duration) over a populated row is a failed
extraction — refuse the write with a loud warning and let the next
scan retry.
audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
manifest.json has referenced /static/icons/icon-{192,512}.png since
April, but the directory was never committed — ~100 404s per browsing
session and a broken PWA install. Rendered both sizes from the
existing favicon book glyph on the Tokyo Night palette
(#1a1b26 background, #7aa2f7 stroke).
audio_reader.templ was the only template emitting <!DOCTYPE html/>
(trailing slash is a parse error), which put the audio player page in
quirks mode — document.compatMode was BackCompat while every other
page is CSS1Compat. Quirks layout rules are a standing hazard for the
player's h-screen/flex page even where it happens to render. Drop the
slash.
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal ' text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.
Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
Clicking the bottom progress slider left it focused, and the reader's
keydown guard treats INPUT focus as typing — so arrow keys stepped the
range input instead of turning pages. With step="any" each press moved
~1% of the book and key repeat crossed the whole book in seconds; the
debounced progress saves then persisted every wild landing (positions
scattered from page 5 to 285 to 100%, "progress no longer saves
correctly after page turns").
Two layers:
- reader.templ: all three progress sliders blur on pointerup, so a
mouse scrub no longer leaves focus behind.
- reader.ts handleKeydown: ArrowLeft/ArrowRight on a focused progress
slider preventDefault, blur, and fall through to the page-turn
handlers; every other key still belongs to the control, so a
keyboard user who Tabbed to the slider keeps arrows for it.
The cleanStaleChunks plugin lives in the shared vite config, so its
closeBundle hook also fired under `vp test` — where generateBundle never
runs, produced stayed empty, and the sweep deleted every hashed chunk
in web/static (28 files → 3). The entries still referenced their old
imports, so every page 404'd and Alpine never started: the whole reader
and media system appeared dead after merely running the test suite.
Guard: skip the sweep when produced.size === 0 — not a build run, no
build output to clean. Verified: build → 28 files, test → 28 files;
back-to-back builds stay byte-identical.
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.
- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
'warning' entry regression) and render behavior.
Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.
jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.
Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
Matches the error/success/info entries visually. bg-yellow-500/90 is
generated by the Tailwind scanner from this file itself (content globs
include web/src/**/*.ts), same as the sibling bg-*-500/90 classes — no
safelist entry needed.
tsc --noEmit and oxlint web/src are now both clean; vp check passes.
- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
declaration (TS2717) — removed; bookPicker/collections lose their
(window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
(escapeHtml) and for..of over querySelectorAll results is legal.
Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
but getToastConfig had no entry, so the warning toast threw TypeError
at runtime instead of displaying. Added the type + config. Uses
bg-yellow-500 (verified present in scanned templates => generated by
Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
list (querySelector('ul') infers HTMLUListElement, conflicting with
the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
line={x,y}); dead getActiveRange() removed (was already tree-shaken
out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
(series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.
Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
The extractor/force-rescan tests assert manifest stamps against the
default version (1), but the setting lives in the SHARED dev DB — any
prior run (or a manual admin-UI bump) can leave it at 2, failing every
stamp assertion through no fault of the code. Each ev-sensitive test
now pins the setting to 1 at start and restores 1 on cleanup; proven
against a deliberately bumped setting (tests pass, row restored).
Force rescan contract change (owner-directed): the editorial-keep skip
is retired. Force rescan now re-derives EVERY field on EVERY book from
all sources — multi-file books included (the folder/zip flows ran the
full chain on create and on manifest-flagged edits, but skipped the
sidecar apply under force). metadata_overrides is the only protection:
ApplyMetadataOverrides re-applies manual values inside the writer, so
manual edits survive; everything else re-derives.
- A force pass also STAMPS each re-derived book's manifest with the
current parser version (success-only — same truthfulness rule as
Phase 10) and closes its open processing issues (Phase 11 self-heal).
Force rescan and the ev-bump trickle now converge: either leaves the
library fully current; the deploy order is bump-first, then Rescan
All (documented in the plan and contract_pins).
- Single-file force rescan already ran the full chain — unchanged,
now pinned.
NEW filter field manually_edited ("Manually edited", boolean):
cardinality(mi.metadata_overrides) > 0 / = 0. Composed-engine (qf)
only — the builder's Book facts group gains it with is true/false;
no value menu, no suggestion surface; family lens inherited.
Tests: multifile force applies sidecar metadata + stamps + override
protection; single-file pin; the filter both directions scoped to the
test library; adjacent suites (Audiobook, Calibre, ExtractorVersion,
CrossLibraryMove, ProcessingIssues, FilterBuilder, SchemaIdempotency)
green. The extractor mechanism itself is untouched — the owner bumps
scan_extractor_version manually via the admin UI (plan Phase 13 §3).
processing_issues only recorded cross-library move rejections; files
that failed to import vanished into an error counter and stdout. The
ledger now records every failure class and heals itself:
Schema (in place, pre-release boundary):
- media_item_id NULLABLE + file_path TEXT (deliberately unbounded —
an over-long path is itself a failure mode; a VARCHAR(500) ledger
column died on the exact 22001 it existed to report — caught by the
new test). Row-less failures key on (library, file_path); item-backed
on (item, type). Widened dedup = COALESCE-expression unique index;
the old table UNIQUE stays (NULLs pairwise-distinct for row-less).
- Conflict-target upsert: repeat failures UPDATE the same row and
REOPEN resolved ones — never duplicates.
Wirings:
- CreateMediaItem failure (single-file + multifile createAudioBookItem)
→ row-less import_failed (error).
- Extraction hard-fail with fallback import → item-backed
metadata_extraction_failed (warning).
- Change-path re-extraction failure (the ev/sidecar guard) → item-backed
metadata_extraction_failed; LogProcessingIssue keeps its signature, a
row-less helper joins it.
Auto-resolution:
- Successful import → ResolveOpenProcessingIssuesForPath (library,
file_path).
- Successful (re)extraction → ResolveOpenProcessingIssuesForItem
(single-file change path, multifile editorial apply).
- Unchanged-skip scans do NOT resolve: extraction never re-ran, so the
degraded metadata is still degraded — the page heals exactly when the
book does.
Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR
page): explicit out-of-set library id → 403, never silent. This
tightens one pre-lens behavior — a nonexistent library id returned
200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md).
List is LEFT-JOINed so row-less rows appear (media_item_id "",
title falls back to the path).
Tests: full lifecycle state machine through real scans (fail → appears,
repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape);
family lens 403s with a fresh non-shared admin; all 7 pre-existing
issue suites green (two expectations widened to the lens, changelogged);
adjacent suites (CrossLibraryMove, Audiobook, Calibre,
ExtractorVersion, SchemaIdempotency) green; unit suites green.
setupTestServer built the settings registry (and wired it into
systemSettingsHandler) but never called authHandler.SetSettings, unlike
main.go:112. The auth handler therefore ran with a nil registry and
defaultTimezone() took its nil-fallback branch, returning "UTC" for every
Register/AdminCreateUser in tests regardless of the system default_timezone
setting — so the 740213f0 inheritance pin in TestAdminCreateUser could never
pass and failed every full-suite run.
Also replace the TestAdminCreateUser "stable-ish" uniquifier (MaxConns is
pinned to 1 by the harness, so it produced the same user every run and
depended on the next run's cleanup sweep) with a real uuid suffix.
Parser fixes (7e4c97f6, genre model v2) never reached rows ingested
under an older parser: the sidecar never changed, so every fingerprint
said 'unchanged'. The manifest JSONB now carries the reserved 'ev'
stamp — the parser version that produced the item's row.
Semantics (all pinned by tests):
- ABSENT ev decodes as 1 ('v1-or-unknown'); stamping is truthful-only:
create paths + post-extraction stores stamp the setting's current
value; deploy-day backfill and the extract-failure guard stay UNSET
(a row never claims a version its data can't prove — a mid-backfill
bump self-heals next poll); repoint reconciliation preserves the
stored ev (stat-only refresh re-extracted nothing).
- NEW setting scan_extractor_version (seeded + registry + getter,
int default 1, category scanner): a manifest whose ev != the setting
re-extracts through the SAME change path (full extraction →
updateMediaItemWithMetadata → fresh stamped manifest). Sidecar-less
books get an entries-less manifest as their ev home (create: stamped;
first-sighting: unset).
- Catch-up bounded: evReextractBudgetPerScan=100, atomic (watcher pass
can overlap a poll scan), reset per ScanFolders — amortized +
resumable across polls; no force rescan, no flood.
- Multifile books included: the ev gate rides multifileSidecarChanged's
shared ladder; ev-driven manifestChanged drives the editorial apply
+ stamped re-store.
Settings API: the unified GET/PUT /api/system/settings now lists the
key (registry-driven, auto-surfaces in the admin UI). Test harness:
the registry is now Loaded + wired into the SystemSettingsHandler
(main.go parity) — settings writes through the API were 503s before.
Verified: unit (ev normalization, budget CAS, GetExtractorVersion
cache paths, registry clamping); integration state machine —
create-stamp ev=1 → equality skips (corruption probe persists) →
wiped scan-state backfills UNSET without re-extract → bump re-extracts
ONCE (sidecar value restored, ev=2) → stable (second corruption
persists); multifile twin green; adjacent suites (Audiobook, Calibre,
CrossLibraryMove, SchemaIdempotency) green; live app restart re-scans
1198 files with 0 changed (deploy-day rule), setting listed at
/api/system/settings.
The client's operator map passed the display string as BOTH the map
key and the OpMeta.key, so the menu clicks transmitted 'doesn't
contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server
rightly rejected them (unknown operator → 400), the count fetch's
catch hid the counter, and flipping away from such an operator never
came back. Only self-named ops (contains, is, has, between…) worked.
- Map keys now DERIVE from meta.key (Object.fromEntries over the op
list) — wire value and label structurally cannot diverge again.
- New scripts/filter_builder_harness.js: a playwright harness that
drives the real drawer in headless Chromium and sweeps every author
operator, capturing filter-count responses and console errors — the
first UI-level regression net for the builder (the server tests
could never see a client-side mis-key).
- Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 →
is empty 2 → refill 8; zero console errors across the sweep.
The count fetch reads the library select at fetch time, but nothing
re-triggered it on a scope change — the number stayed stale (8 across
all books) after switching to a library where the rules match nothing.
The picker's change event now reschedules the count, same as rule
edits do.
The browse-on-click blank sentinel was trimmed to empty (correct), but
the typed query had already been bound unconditionally — the id-source
suggestion query has no score CASE to reference it, leaving an unused
parameter Postgres rejects (42P18). Every click of an empty library /
collection / added-by box answered 'No matching values' via the error
path.
- ComposedFieldValues binds the typed query ONLY in branches that
reference it and only when non-blank; the score expression is
'0 AS score' for browse (no param) and the similarity CASE when
typing. composedIDSourceValues takes the raw typed string and binds
it only when the name filter is built. userBind already followed
this discipline; audited every bind site in the file (fuzzy helpers,
composed search, count) — all interpolate immediately.
- Tests: library browse under rules (exactly the surviving library,
count pinned), series browse blank, added_by browse blank (no
error), collection browse blank (offered with count) and under a
zero-match rule (never offered). Verified end-to-end against the
live dev data: author+Sapkowski AND narrator+Kenny → Audiobooks (8).
- Blank typed queries rode the STATIC autocomplete path for the classic
six fields, where a single space similarity-matches into nothing —
clicking an empty value box with no other filters said 'No matching
values' in a library full of values. Blank = browse now always rides
the composed engine (which trims and answers the top values); static
keeps its pinned contract for real typed queries. Regression-pinned:
browse with no rules lists the field's values.
- The value popup now drops BELOW the input (top: 100%), not over it.
- Field-menu group headers lost position:sticky — the search box alone
stays pinned.
- The flash was a race: on localhost the suggestion fetch resolved
between the focus (mousedown) and the click event, so the just-opened
menu was closed by its own click-outside handler — the input sits
outside the menu div. Clicks anywhere inside the value cell now
never close the menu; outside clicks do.
- The menu opens immediately in a loading state; an empty browse
renders 'No matching values' instead of nothing. Plain free-input
fields (page_count etc.) open no menu at all.
- Field menu: the filter input moved outside the scroll region — it
sticks while the grouped list scrolls beneath it.
- Per-rule connectives: the chip before each rule flips that rule
between AND and OR — OR-joined neighbors merge into an invisible
OR-block, AND pulls them back out. The tree stays the single source
of truth (implicit blocks are a client marker the server ignores),
so deep links, saved filters, and the composed engine are untouched.
Group-mode pills are gone; explicit groups remain as pure structure
(+ group). Compile limit, same as Excel: flat chips express
AND-of-OR-groups; a top-level OR of AND-blocks still needs nesting.
- Browse-on-click: clicking an empty value box now fetches the field's
top values UNDER the other rules (blank typed = browse, not a
similarity filter) — 'assign an author, click Publisher, see their
publishers' without typing. Server: the composed suggestion engine
trims the typed query; client: the >=2-char guard no longer blocks
the empty query, browse limit 15 vs 8 while typing.
- Tests: the browse case pinned (blank sentinel + rules → the field's
values under those rules, zero-match values absent).
The ALL/ANY pills required mapping ALL→AND, ANY→OR that nobody should
have to learn. The pills now say AND / OR directly (wire values stay
all|any — server contract untouched), and the AND/OR text between
sibling rules became a clickable toggle for its parent group, so the
switch lives exactly where you're looking.
- Root bug: incomplete rules (the ready rule, every new group's first
rule) were sent to the server — the live-count fetch toasted a 400
and every suggestion fetch failed silently (why author typing showed
no dropdown). Fix: completedTree() prunes incomplete rules and empty
groups; the count fetch skips when nothing is complete, suggestion
fetches send only the other COMPLETED rules, and serialization wires
only completed rules (an unfinished rule was never a filter).
- Group remove button was x-show'd away on depth-0 groups — exactly
where user groups live. Always visible now.
- Field menu: filter input at the top (Enter prevented), no-match
state; group headers restyled as flush-left tinted sticky bands with
options tabbed in beneath (user's layout pick).
- toast.ts: filter-count fetches join the suggestion-fetch exemption
(a failed background hint never interrupts).
- Field registry (services/filter_registry.go): ~55 typed fields —
every book column, file/lifecycle facts, the three HOLDING
STRUCTURES (library, collections with excluded-aware membership,
series with FULL primary+alternate_info membership), and the whole
user-relative class (status, progress, last read, my rating, review,
notes/highlights presence, note/highlight text, time spent, pages
read, collections, added by) reading the VIEWER's rows. Operators
are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
shape), the field-aware suggestions, and a true-COUNT live count.
Mandatory WHERE head everywhere (family lens + integrity facts).
Values are always bound parameters — no user string reaches a
field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
fields/operators/malformed values are 400); qf wins over flat params
on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
field-values autocomplete upgraded in place — any registry field
key, qf binds the OTHER rules (symmetric under-filter suggestions,
zero-match values never offered); classic six without a tree keep
their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
markup, Alpine tree state, sanctioned island; grouped field menus,
operator menus with red negations, field-aware value menus, live
count, chips per rule with red negations, URL rehydration, smart
serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
numbers/dates/empty), every user-relative field class with the
my-data weld (two readers, different worlds), note-text substring
pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
403s), suggestion-under-rules exactness, qf validation + precedence,
flat-vs-tree equivalence; registry completeness unit test.
- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
tags/narrator/series/language) treats library_id as OPTIONAL —
absent aggregates every VISIBLE library (family lens in-query);
explicit out-of-set library_id is now 403, never a silent pass.
The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
(console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
Libraries pins the fix (200 + cross-library aggregation, family
lens, 403). Contract-pins changelog entry added.
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.
- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
listable facets in a single round trip: each arm omits its OWN
facet's filter block and carries every other registered facet's
block (copied mechanically from SearchMediaItemsUnified) — classic
faceted semantics: with filters active each facet counts under the
others; with none, blocks are neutral and the counts are the v1
global-per-library ones. One query serves both cases. The 14
per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
({facets: {param: [{value, display?, count}]}}), filters bound via
the shared ParseMediaSearchParams, library_id optional (the
All-Libraries view, family lens still enforced); facet=X keeps the
pinned single-facet shape (now routed through the same query with
neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
value lists — registry order, Batch D library-type scoping
(FacetsForLibraryType: manga_type on manga, reading_direction on
manga+comics, narrator on audiobooks; All view = union), prettified
genre labels + language display names, active values marked. Family
lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
apply with the CURRENT filter query string; a value click toggles
that facet's comma-joined filter (hidden input where the drawer has
no field — same as deep links) and re-applies the form; chips and
URL stay in sync. Values are server-rendered (rendering doctrine);
bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
implementation (services' display enrichment shares it; templ wraps
it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
registry facet's param, the bools as pgtype.Bool.
Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
Two suite failures, neither caused by scanner code (the Phase 6 diff
touches no handler):
- search_test invalid_library_id expected the pre-family-lens 404;
7e586df4 shipped the AGENTS-mandated 403 for every out-of-set id
(existence must not be enumerable) and docs/contract_pins.md:195
documented it — this one subtest's expectation was missed. Pin
widened to the shipped 403.
- TestWebSocketPingPong deleted: it asserted a text-level ping→pong
exchange no server code has ever implemented and no client has ever
sent; it passed vacuously through its read-timeout path until
interleaved scan_progress broadcasts from the live server's scans
reached the socket first. Keepalive is the standard protocol-level
ping the hub already sends. Never-built behavior is not a pinnable
contract.
Both recorded in docs/contract_pins.md. Full container suite is green
except the documented pre-existing TestAdminCreateUser red.
Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.
- Change fingerprint gains the manifest compare in both processors:
changed := size differs || track payload differs || manifest drifted.
The ladder runs against the bulk-loaded map entry (stat pairs only,
zero extra queries); NULL stored manifest backfills without
re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
editorial columns (updateMediaItemWithMetadata, overrides protected)
and re-runs the playlist hook. The apply runs BEFORE the focused
payload write: the generic update's format-group pass derives mime
from the sentinel (none) and chapters from metadata.ChapterCount
(unset) — the payload write must win (rescanMultiFileAudioBook's
ordering). Track-only changes keep the existing editorial-keep
update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
sentinel — the exact representative the extractor reads — via
detectSidecarChangeWithRepresentative, so manifest and extractor can
never disagree; a directory-basename cover is ignored by the
extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
folder path always did (a returned zip book stayed archived
forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
non-nil empty &MediaMetadata{} fallback bypassed the update path's
filename-title guard (preExtracted == nil only) and would blank the
title on a failed extraction; the row is now left untouched and the
manifest refreshed. Empty-success extraction (sidecar removed from a
tag-less book) now passes nil so the create-equivalent chain (tags →
filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
they were previously uncounted.
Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
Part 2 of the faceted plan, Phase 5 (steps 5-6): the scan that used to
print ~4 lines per unchanged file now prints ONE summary line, and a
poll tick landing mid-scan no longer stacks a second full scan.
- Quiet scans (§4): the walk loop is silent — inserts, changes, and
errors log inside processMediaFile; the duplicate
'Successfully processed' branch (both branches printed it) and the
per-file 'processing / already exists / same size, skipping' chatter
are gone. Summary carries the trigger and a changed counter:
'Scan of <folders> (trigger: poll): N files, N changed, N new, Xs,
N errors'. Triggers threaded through every enqueue site: poll
(startBackupScan), watcher (dirty-dirs flush), admin (scan API),
initial (startup scan).
- Enqueue coalescing (§5): Worker.HasActiveScanForLibrary (JobResult
gains additive job_type so only SCAN jobs coalesce — an import
sharing the library id never blocks one). enqueueLibraryScan skips
silently; the admin scan API answers 409 with the active job id.
Enforces the one-scan-per-library invariant the bulk scan-state map
leans on.
- Watcher capability probe (§4): ONE Add on a root folder; failure
disables the watcher with a single startup line (replaces the
per-directory warning spam on inotify-broken deployments). The
watcher stays an optional latency reducer over scheduled scans.
- Per-directory sibling cache (§6): hasSiblingBookFileCached caches
the ReadDir per directory for one scan — loose-image manga re-stats
thousands of images sharing a handful of directories.
Tests: TestHasActiveScanForLibrary (unit — running/pending coalesce,
terminal jobs don't, non-scan jobs don't, empty library never, nil
worker safe). Full scanner suite green in the container.
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.
Part 2 of the faceted plan, Phase 3 (steps 1-2). The periodic scan
issued one GetMediaItemByFilePath per scannable file (~3,094 queries
per scan in prod, nearly all answering 'unchanged, skip') plus one
GetLibraryByFolder per file for folder resolution.
- NEW media_scan_state table: the scanner's per-item fingerprint
cache. Deliberately NOT a media_items column — the library-scoped
list emits raw sqlc rows as JSON (7537063c), so scanner bookkeeping
must never leak into API payloads nor churn the model or pins.
Manifest JSONB shape v1 documented in the schema (n/s/m/h; ev
reserved for the Phase 4+ extractor-version mechanism).
- GetScanStateByLibrary: lean bulk projection (id, file_path,
file_size, archived_at, missing_scan_count, file_sha256, manifest).
archived/hash columns ride along so the skip path's archive-restore
and hash self-heal checks stay DB-free too (refines the plan's
4-column sketch; same goal, narrower invariant).
- ScanFolders: per-run reset, per-folder library cache, one bulk load
per distinct library. processMediaFile: map hit answers existence +
size + flags with zero DB reads; force-rescan and size-change
branches re-read the full row (the change path may be expensive);
write-through on create/size-change keeps later passes honest.
- Multifile folder/zip books keep their full-row lookup: it is one
query per BOOK ROOT (not per track) and needs audio_metadata for
the duration-probe check. They join the map in Phase 4, when the
manifest compare replaces their fingerprint check.
- nil map (watcher's scanDirectory path, bulk-load failure) falls
back to per-file lookups — behavior-preserving; the failure case
logged once per scan.
Test: TestScanStateBulkLoad (unchanged rescan leaves updated_at
stable; API-created rows are seen by the bulk load; size changes
still update in place). Container runner.
Frontend half of the faceted-navigation Phase 2. 'Everything filterable
should be clickable': every displayed facet value links to the bookshelf
pre-filtered by its raw stored value; display forms are prettified at
render, never in the DB.
- templates/facet_links.templ: FacetLink (inline links) + FacetBadge
(badge links) to /bookshelf?<facet>_filter=<value>; the raw value
rides the param while the shown text is prettified, so matching and
display can never disagree. Series/tags keep their dedicated pages
by design; the card media-type icon stays unlinked (a derived kind,
not a column value — deep-linking it would send wrong values).
- book_detail.templ: publisher, language, edition, genre, imprint
cells + narrators line + age-rating/story-arc badges are now links.
- prettifyGenre (templates/utils.go + unit tests): title-cases only
entirely-lowercase values; small words stay down except first
position; & preserved. TS port for chip labels documents the twin.
- bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine
renders chips from client-local form state (sanctioned island);
one bubbled htmx:afterRequest hook keeps chips + shareable URL in
sync; removeFilterChip clears the facet and re-applies. URL deep
links now accept all 18 facet params (hidden inputs where the
drawer has no field).
Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering
ownership guard (make lint-rendering) green.
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.
- Nine new filter params on SearchMediaItemsUnified: story_arc,
imprint, edition (scalar-fuzzy), age_rating, format_group,
manga_type, reading_direction (exact), explicit, abridged
(presence-bound bools). Comma = OR-within multi-select on every
text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
> empty (unknown-only stays tags-only). Type-words (Manga, Comics,
Graphic Novel) join the demote list. Both 'adventure' and 'action &
adventure' are shelves — provider data uses both and the verified
traces pin them.
- Language filter UX: comma elements normalize through the ingest
language map, so display names ('English') match rows stored as ISO
codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
driven value+count listing (14 static per-column queries, family-lens
join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
403 on the search endpoints (was a silent empty set) — JSON branch
and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
search JSON endpoint and the HTMX grid partial; the HTML branch
previously dropped publisher_filter and narrator_filter silently.
Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
Completes the A2c acceptance scenario: click an author on a book
detail page -> land on the bookshelf filtered to everything that
author wrote (including their share of co-authored books).
- Media detail + unscoped list resolved maps expose subtitle,
explicit, authors, publishers (the library-scoped raw branch and
the search endpoint already did — raw rows follow the SELECT).
- SearchMediaItemsUnified SELECT carries the new columns; the search
endpoint serializes the row directly, so clients get them there too.
- book_detail.templ renders each author as a link to
/bookshelf?author_filter=<name>.
- Bookshelf honors filter params in the URL on load (author_filter,
series_filter, genre_filter, publisher_filter, tags_filter,
narrator_filter, language_filter): pre-fills the form, applies it to
the first grid load. Comma-joined values are multi-select via the
server-side string_to_array split. Filtered views are shareable.
Pins widened: search rows (+7 keys), resolved keys (+4); changelog
entries in docs/contract_pins.md. Android follow-up plan written to
~/.opencode/plan/bookhoard-app-v2-compat.md.
Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.
Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.
Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.
Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.
Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).
AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.
Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7).
Auto-playlist collections (§2.4): the scanner turns obviously-playlist
sidecar series (explicit '(Reading Order)'/chronological/publication/
in-order/timeline markers only — precision over recall) into curated
static collections: upsert by view_settings.imported_from_series then
name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a
stay unpositioned rather than guessing), membership add-if-missing,
never removed or re-ordered by rescans, provenance in description +
view_settings, show_on_dashboard true (dashboard-eligible by design),
'scanner.auto_playlist_collections' setting (default on, Settings
Registry + schema seed).
Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST
then added_at ASC (was added_at DESC — walked playlists backwards);
PUT /api/collections/:id/items/order applies full-order rewrites
(owner-only, service-gated).
hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard
gains viewer_user_id + hide_finished (LEFT JOIN reading_progress,
COALESCE percentage < 0.999) and viewer_percentage rides the row for
card graying; GetCollectionUpNext serves the collection's reading-queue
head with the family-lens visible-library array enforced.
Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT —
the show_on_dashboard gate (never writable for user rows since the
column shipped) is gone from GetUserCollectionsForDashboard; hiding
stays dashboard-prefs (hidden_collections) territory; hide_finished
filtered-everything now emits a Complete section instead of silently
vanishing; sections JSON carries is_playlist/complete.
Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by
title ASC — NULL/tied numbers previously returned heap (scan) order,
which is what the user saw.
Frontend custom-section-builder note: the builder has sent
show_on_dashboard: true since its creation; the API never bound it.
User-row visibility is now intentionally prefs-governed, so the field
stays unbound by design (documented in code).
Pins widened (docs/contract_pins.md): collection detail + up_next;
dashboard sections + is_playlist/complete; new reorder endpoint
recorded.
Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar
series split, collection auto-creation with position 7, rescan
idempotency, owner-only reorder, finished-filter + completion marker +
Up Next drain) plus the full affected integration sweep green.
Phase 2a of the metadata overhaul (plan §2.1-2.3, §2.5-2.9).
The headline bug: ABS writes sidecar series as an array of STRINGS
(["The Witcher #3", ...]); the parser only accepted {series, sequence}
OBJECT arrays, so every ABS-matched book silently lost its series (found
live: 9 sidecars, zero series ingested; only folder-structure Malazan
survived). parseSeriesCandidates now normalizes string arrays, object
arrays and Libation plain strings.
Three-way series assignment (plan §2.2):
- primary bibliographic series -> series/series_number columns
- additional real series (Drizzt: Icewind Dale #1 = Legend of Drizzt #4)
-> alternate_info JSONB, comic-established shape
- ordering constructs ('The Witcher (Reading Order)') -> PlaylistSeries
candidates; NEVER the series column. Detection is explicit-marker-only
((reading order|chronological|publication order|in order|timeline) as
parenthetical/suffix) — precision over recall; a series merely
containing 'timeline' mid-name stays a series.
Sequence policy: leading-number extraction ('Book 2' -> 2, '2,
Dramatized Adaptation' -> 2, '1a' -> 1); lossy extractions preserve the
raw text in alternate_info.series_sequence_raw.
Smart genre (deterministic lexicon): compound Audible blobs split
('Epic, Action & Adventure, Military, Sword & Sorcery' -> 4 candidates),
alias-normalized, format noise demoted ('Audiobook' never wins),
most-specific canonical genre takes the genre column, all candidates
stay in tags.
Language: display names normalize to ISO 639-1 on ingest; ROOT-CAUSED
the update-path drop — CreateMediaItem call site never set Language
(explicit NULL over the column default), the update path did; create
now carries it.
Title-derived series fallback (§2.7): 'Baptism of Fire: The Witcher,
Book 3' 2-level folders split conservatively (only when the post-colon
tail matches ', Book N'), subtitle parsed for series+number, blanks-only
precedence, matching-name number fill, zero display mutation. Works
with no sidecar and no network.
Ingest fixes: subtitle/explicit land in their columns (audio tag
subtitle stops joining into title); full authors[] array (was [0]-
only); sidecar descriptions sanitized via bluemonday UGC policy
(vendored).
metadata_overrides: verified scan-update path already applies
ApplyMetadataOverrides; subtitle/explicit/authors covered by the Phase 1
override adaptation.
Tests: new series_parsing_test.go (string-array sidecar e2e, Drizzt
alternate_info, lossy raw preservation, playlist classification,
title-derived fallback incl. no-mutation + blanks-only, genre lexicon,
language map); audio-tag subtitle pin updated to column behavior;
calibre/ABS 2.5-series_index pins widened per float change (changelog'd
in phase 1).
Deferred to 2b: auto-created playlist collections (§2.4) — settings
toggle, collection upsert, ownership.
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
@view-transition { navigation: auto; } lets Chrome 126+ / Safari 18+
crossfade same-origin navigations — sidebar links and the series
page's library-switch navigation get a subtle fade with zero
JavaScript, softening the switch to full SSR navigation. Pure
progressive enhancement: Firefox and older browsers simply navigate
instantly. style.css regenerated via build:css:prod.
web/static/*.js is gitignored and built at image-build time, so the
hardcoded ?v= on the module script tag is the ONLY mechanism that
evicts a stale bundle from browsers. Shipping the series twin deletion
without a bump would let cached main.js (with the deleted renderer)
keep running on prod, making the paginator fix appear not to work.
Found while shipping; easy to miss because the bundle never appears in
git status.
The bookshelf select (#library-select) sits inside the htmx filter
form and the SSR route honors ?offset= — wiring a change handler or
hx-trigger="change" on it would let header.ts's synthetic restore
event re-fetch the grid at offset 0, the same stomp class that broke
the /series paginator. Comment-only guard; regenerated
bookshelf_templ.go carries it into the emitted markup.
renderDashboardCollections/renderSectionHTML re-render dashboard
sections from /api/dashboard/sections JSON on library switch, settings
save, and refresh — deliberately kept in TypeScript to preserve the
switch fade on the app's most interactive page. First-paint section
markup remains templ (Dashboard handler in internal/router/frontend.go).
Recorded in the Rendering ownership section as a sanctioned exception
so it is not silently 'fixed' without restoring a templ-partial fade
path, and so the pattern is not cloned to new pages. (User-directed:
the dashboard keeps its fade; every other page stays templ-rendered.)
New integration test mirroring a prod bulk import: a dedicated test
library with 30 series whose items share ONE identical created_at
(single-transaction NOW()), the exact data shape that made prod
pagination look broken.
Pins, via the real SSR /series route:
- page 1 renders exactly 24 deterministic series (01-24) with chip
"Page 1 of 2", no Previous, and a Next href carrying library_id and
page (templ escapes & as & inside attributes)
- page 2 renders the remaining 6, disjoint from page 1, chip
"Page 2 of 2", no Next, Previous href correct
- missing page param defaults to page 1; page=0/-3/two clamp to page 1
Guards both the SQL tiebreaker and the /series handler contract;
would fail on any return of the stomp or an unstable sort.
GetDistinctSeries ordered by MAX(created_at) DESC alone. Bulk imports
inserted in a single transaction share one NOW() timestamp, leaving
large numbers of (library_id, series) groups tied on MAX(created_at);
Postgres does not guarantee tie order between executions, so
LIMIT/OFFSET pages could repeat or drop series across page loads.
Add the GROUP BY keys (library_id, series) as tiebreakers so the sort
is total and deterministic. Comment documents why the tiebreaker is
required so it is not dropped as noise. sqlc regenerated (interface
unchanged; the comment flows into querier.go docs).
"Page N of M" now renders as small secondary-color text on the page
background rather than an accent chip (user preference; the old TS
twin happened to render this way and the chip was only being seen
after JS took over). With the twin deleted, this templ markup is the
only paginator rendering, so the look is stable.
web/src/series.ts carried a full TypeScript twin of the templ page —
cards and paginator rendered via innerHTML from /api/series JSON,
always hardcoded to page 1. Combined with the header restore dispatch
(previous commit) it made every /series load stomp server-rendered
pagination back to page 1, which is the paginator bug reported on prod
(9 pages of series, Next kept landing on page 1, per-library only).
Rendering ownership (AGENTS.md): this page now has NO client renderer.
- grid and paginator are rendered only by templates/series.templ
- a library switch is a plain SSR navigation to
/series?library_id=...&page=1, keeping the URL the single source of
truth for both library and page state
- drops the duplicate /api/series fetch that ran on every page load
rendering-allowlist.txt: series.ts baseline entry removed — the file
no longer builds HTML anywhere.
restoreLibrarySelection() ran on every page load via the header's
x-init and always dispatched a synthetic `change` on #library-select
after setting its value. On SSR pages the dropdown already renders the
stored library (setSelectedLibrary keeps the cookie in sync and
resolveLibrary renders from it), so the synthetic event re-ran the
page's switch handler, whose fetches are hardcoded to page 1 /
offset 0 — stomping freshly rendered content.
Visible impact: the /series paginator snapped back to page 1 on every
navigation whenever a specific library was stored (All Libraries
short-circuits restore, which is why it only failed per-library).
Silent impact: dashboard and collection-detail pages re-fetched their
content on every single load.
The restore now re-fires `change` only when the dropdown genuinely
drifted from storage; the common synced case is a visual no-op.
Integration tests require the containerized dev DB and bind-mounted
paths — a bare host `go test ./cmd/server/tests` fails on database
auth on every machine not running the compose stack (the old doc
prescribed exactly that flow). development.md now documents the
container runner and the iteration rule: run ONLY your own tests via
a filtered container invocation while working, and reserve full-suite
runs for pre-commit validation — several tests read live shared state
(system_settings, real library folders, the periodic backup scan's
websocket broadcasts) and flap on data or timing a change never
touched.
Binary media fixtures are never committed: internal/services/
testdata/ is gitignored by policy (the audio metadata tests read
real-format binaries that ffmpeg cannot synthesize — Audible freeform
atoms — so they live untracked on the machines that run the suite).
The new "Media fixtures" section carries the ffmpeg recipes for every
supported container, tag writing, embedded covers and ffmetadata
chapters, plus the manual end-to-end recipe for the live system
(one book per subfolder — a directory with ≥2 audio files ingests as
one folder book).
The audio reader pointed <audio src> at ONE file — correct for m4b but
impossible for folder-of-tracks and zip books (the element holds a
single source). The player now generalizes to a track list built from
the /chapters manifest: single-file books are exactly one track whose
url is the whole file (zero behavior change — same element, same URL
flow), multi-file books swap src at track boundaries.
Mapping helpers (exported, unit-testable): trackIndexFor resolves a
global position to the owning track, localOffsetFor/globalSecondsFor
convert between global seconds — the coordinate system of chapters,
bookmarks and audio_offset_seconds — and (track, local offset). Every
consumer keeps global semantics: scrubber, skip ±N, bookmark saves,
?loc=audio: deep links and progress pushes are byte-identical payloads
server-side; the server contract does not move.
Mechanics: setActiveTrack swaps src and queues the position for
loadedmetadata (setting currentTime before the new metadata loads
resets it); ended advances to the next track and keeps playing;
timeupdate reports globalPosition(); playbackRate/volume re-apply
after swaps; src is deferred until the manifest loads (data-file-url
is a directory/zip root for multi-file books, not playable) with the
catch branch falling back to it for offline single-file starts.
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".
Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).
Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.
Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.
Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.
Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
- endpoint semantics: non-admin cancel is 403, unknown job is 404
- mid-scan safety: start a scan of 1200 unparseable .epub files and
cancel it, then pin the invariant that no media item is marked
missing or archived. The canary item's file is lexically LAST in
the folder, so an abort that (incorrectly) ran the archive pass
would treat it as unvisited and trip the assertion. Assertions
hold under every interleaving - whether the cancel lands mid-walk
or the scan already finished - so the test cannot flap against
timing.
- re-cancelling a terminal job is a 409
The test library is created through the shared
createTestLibraryWithFolder helper, which registers API-delete
cleanup so runs leave no residue in the dev database.
Worker-level unit tests live in internal/services/worker_cancel_test.go
(previous commit).
- per-row Cancel button in the scan-progress card; listeners are bound
after the TS island renders its rows (module scope has no inline
handlers), and the button is removed once its job turns terminal
- cancel-all icon button in the card header: resolves the server's
active job list first, so it also covers scans this page never
started (e.g. started before a refresh), then cancels each one
- polling treats cancelled as terminal, averages overall progress
only over jobs that can still advance (so the bar can reach 100%),
and the results panel reports cancelled scans instead of claiming
"Scan Complete!"
- sidebar spinner resets on the scan_cancelled websocket event (same
quiet handling as scan_error)
- setup wizard shows "Cancelled" as a terminal status in its poller
No new innerHTML assignment sites - rendering-ownership baseline
unchanged.
Cancels a pending or running scan job. Responses: 200 with the job id
and status on acceptance, 404 for unknown jobs, 409 for jobs already
in a terminal state (completed/failed/cancelled).
Additive route in the existing admin scanner group; no contract pins
affected.
ScanFolders never checked its context, so a cancelled job's walk ran
to completion no matter what the worker's cancel did.
- ctx checks at every stage boundary (file-counting pass, per folder,
before and during the archive pass) and inside the walk callback
(fs.SkipAll): the file currently being processed finishes, then the
scan aborts with context.Canceled, which processJob classifies as a
cancelled job rather than a failed one
- the archive/purge pass is skipped entirely when the scan was
cancelled. It only visits what the walk visited, so after a partial
walk the unvisited (but on-disk) files would be treated as missing,
archived, and eventually purged. Cancelling is a UI convenience and
must never destroy library data.
CancelJob existed but was dead code: it only flipped a status in the
in-memory results map - it never cancelled anything, and processJob's
unconditional completion write overwrote the marker anyway.
- derive a per-job context in processJob (parent is the handler ctx
for manual scans, Background for auto-scans) and register its
cancel func under the job id; previously every manual scan shared
the handler's process-lifetime context, so no targeted cancel was
possible without killing everything
- skip execution at dequeue for jobs cancelled while still queued
- record a pending JobResult on BOTH enqueue paths (Enqueue used by
auto-scans, EnqueueJob used by the API): queued jobs were invisible
to status polling (404 until a worker picked them up) and could not
be cancelled before execution started
- cancelled jobs keep their last real progress instead of a fake 100%
and drop the meaningless "context canceled" error text
- broadcast the new scan_cancelled websocket event for cancelled scan
jobs instead of scan_complete, so clients reset their scanning
indicators rather than reading the abort as a finished scan
- sentinel errors (ErrJobNotFound, ErrJobNotCancellable) so HTTP
handlers map outcomes to 404/409 without string matching
Unit tests (worker_cancel_test.go) pin: queued-cancel never executes,
running-cancel invokes the context, error mapping, pending recording
on both enqueue paths.
The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.
- Ingest: the KOReader sync loop, on an unresolvable file, upserts
unlinked_books (one row per device + content id; re-syncs bump
last_seen_at and refresh device-reported metadata) instead of
dropping the file. Schema gains the (device_id, content_id) unique
index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
— devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
when the caller's queue is non-empty (hidden-unless-needed), linking
to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
verifies device ownership and target-item visibility (403s) and
creates the device file alias, so the next sync matches by path.
SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
— triage candidates, not part of the live flow.
The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.
Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):
- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
order (ebooks, comics, manga, audiobooks) and appends unknown future
types by name; LibraryTypeDisplayName renders labels with a
capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
LibraryService.GetLibraryTypes, falling back to the built-in type
names on DB failure so the forms stay usable (CreateLibrary still
resolves the name against the DB, so the fallback is never
authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
into the two pages
Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
/admin/library must offer all four library types, audiobooks exactly
once (options come from the DB loop, not a hardcode)
- BookMatch now carries title/author/cover (the fields the old client
renderer always assumed but the API never sent — cards would have
shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
match cascade server-side; searchMatches swaps it via htmx.ajax
instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
templ page has no route, the live linking.ts renderer targets a
container no template has, and its Link button calls a
window.showMatchModal that does not exist. This commit ships the
scoped backend + partial; wiring the UI is a product decision.
Where the browser supports it (Chrome 111+, Safari 18+, Firefox 139+),
switchWithTransition now uses the native View Transitions API: the old
shelves are snapshotted, the fetch + swap happen behind the dim, and the
two snapshots crossfade — no blank phase, no manual opacity
choreography. The dim + spinner is hidden inside the update so it blends
away as part of the transition. Browsers without the API keep the
existing dim + fade sequence unchanged. Zero dependencies — the
crossfade is the browser's own.
The switch overlay intended a 50% dim (bg-opacity-50) but set its
background with an inline style — Tailwind's bg-opacity variable does
not apply to inline styles, so the overlay rendered fully opaque. Every
library switch wiped the material area to a solid sheet (chrome stays
visible: the sidebar sits at z-60 above the overlay's z-50) and
hard-cut back when the overlay dropped — reading exactly as a new page
instead of a fade. The dashboard showed it worst because its switch
holds the overlay longest (sections fetch + awaited week-widget refresh
behind it).
- overlay background is now theme-aware translucent
(color-mix 55% bg-primary over transparent); the dead bg-opacity-50
class is gone
- the dim lifts before the fade-in starts, so the new content's fade
plays fully visible rather than being hard-cut mid-transition
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:
- QueryBooks post-filters the cascade by the requester's visible-library
set (absent-from-results — a hidden library's book simply never
matches), before determineAction so the suggested action reflects the
filtered set. BookQueryRequest gains a server-side-only
VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
(DeviceExistsForUser) and the target item's library is visible —
403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
still a stub, but the route no longer answers for other users'
devices).
Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.
And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.
Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
They never did: filterConflicts/toggleAllConflicts/updateBulkActions
were referenced in the templ but defined nowhere, and the bulk buttons
called bulkResolve()/bulkDismiss() with no arguments against functions
expecting (strategy, conflictIds).
- conflict cards carry data-status; the status dropdown filters them
client-side (pure client state — allowed to be JS), applied on page
init and re-applied after every partial refresh, with a
'no conflicts match this filter' note when everything is filtered out
- select-all toggles only the visible cards; the bulk bar shows the
selected count and resolve/dismiss now gather the checked ids and the
chosen strategy (the bar's own buttons use resolveSelected()/
dismissSelected() wrappers)
- dead onchange= globals became Alpine @change handlers on the server-
rendered controls; partial swaps re-bind via Alpine.initTree (already
in refreshConflicts) and re-apply filter + bulk state
Visible change: the dropdown's stated default (Unresolved Only) now
actually applies on load, so resolved conflicts are hidden until the
filter is changed.
The library admin page carried an inline <script> block of global
functions (toggleLibraryPanel, openEditModal, openDeleteModal,
openFolderBrowser plus the delete-modal close listener). They move into
the admin Alpine component as typed methods; the page body gains
x-data="admin" and the call sites switch onclick= -> @click.
Behavior unchanged.
Guardrail: make lint-rendering (wired into make test) fails when a
web/src file builds more HTML template literals than its recorded
baseline (web/src/rendering-allowlist.txt, seeded at 18 sites across 9
files post-twin-deletion) or when a .templ file gains an inline script
block. theme_flash_guard.templ is the only sanctioned inline script;
web/src/reader is a sanctioned island.
theme.ts: Array.from(classList) — DOMTokenList spread needs
lib.dom.iterable, which the tsconfig does not include.
Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.
- collections: the library switcher swaps in the new books-grid partial
(GET /collections/:id/books-grid) instead of re-rendering cards from
the /api/collections JSON. Book loading is extracted into
Config.loadCollectionBooks, shared verbatim by the page and the
partial (smart / dashboard-query / manual branches, family-lens
scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
(GET /conflicts/list) with the stats bar as an out-of-band swap,
instead of the TS twin — which rendered a degraded card (no
checkboxes, no timestamps), pointed at a window.showResolveModal that
does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.
Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
Codifies the standing rule discussed for the templ/JS split: content
derived from server data is rendered by templ and refreshed via templ
partials (htmx.ajax), pure client-local state may render in TS/Alpine,
web/src is TypeScript-only, and inline <script> in templ files requires
a pre-paint justification (theme_flash_guard.templ only). Lists the
sanctioned JS islands (reader, Chart.js, toasts, password validation).
Also refreshes gotcha #1: the 'harmlessly masked' aside is stale — the
quoted-attribute sweep fixed every occurrence; the note now points at
templates.JSONArg for free text.
2026-10-18 12:28:25 -04:00
860 changed files with 49408 additions and 21289 deletions
Canonical reference: docs/developer/api/admin/hash_conflicts_api.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/admin/list_users.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/admin/update_user_max_devices.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/analytics/get_analytics.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/analytics/get_device_usage.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/analytics/get_popular_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/authentication/login.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/authentication/logout.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/authentication/password_rules.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/authentication/refresh_token.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/authentication/register.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/auto_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/bulk_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/create_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/delete_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/get_book_matches.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/get_device_file_aliases.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/get_unlinked_book_suggestions.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/get_unlinked_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/link_book.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/search_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/book-matching/update_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
docs:"## Bulk Delete Media Items\n\n Deletes multiple media items in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-delete\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_ids` (array of strings): Array of media item UUIDs to delete\n\n **Response:**\n - `results` (array): Results for each deletion attempt\n - `total` (number): Total number of media items processed\n - `deleted` (number): Number of successfully deleted media items\n - `failed` (number): Number of failed deletions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_ids\": [\n \"uuid-1\",\n \"uuid-2\",\n \"uuid-3\"\n ]"
docs:"## Bulk Update Media Items\n\n Updates multiple media items in a single request with different fields for each item.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-update\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_updates` (array): Array of update objects\n - `media_item_id` (string): Media item UUID to update\n - `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)\n\n **Response:**\n - `results` (array): Results for each update attempt\n - `total` (number): Total number of media items processed\n - `updated` (number): Number of successfully updated media items\n - `failed` (number): Number of failed updates\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_updates\": [\n {\n\
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/bulk_add_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/bulk_assign.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/bulk_remove_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
docs:"Create a custom collection with filter rules.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| name | string | Yes | Collection name |\n| icon | string | No | Emoji icon |\n| description | string | No | Collection description |\n| show_on_dashboard | boolean | No | Show on dashboard (default: true) |\n| auto_assign_rules | string | No | JSON string of filter rules |\n| manual_book_ids | array | No | Array of book IDs |\n| match_type | string | No | \"all\" (AND) or \"any\" (OR) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\
\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
Canonical reference: docs/developer/api/collections/create_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/create_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/delete_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/delete_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/get_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/get_collection_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/list_collections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
code:"function onResponse(res) {\ntest(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\nexpect(res.body.items).to.be.an('array');\n });\n}"
docs:"Preview collection with filter rules before saving.\n\n**Endpoint**: POST /api/collections/preview\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| rules | array | No | Filter rules to evaluate |\n| manual_book_ids | array | No | Manually selected book IDs |\n| limit | int | No | Max items to return (default: 20, max: 100) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"rules\": [\n {\n \"id\": \"rule1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Fiction\",\n \"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}\n```\n\n## Response\n\nReturns array of matching book items.\n\nThis endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection."
Canonical reference: docs/developer/api/collections/preview_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/reorder_items.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
docs:"## Bulk Add Books to Collections\n\n Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/bulk-add-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `operations` (array): Array of collection-book operations\n - `collection_id` (string): Collection UUID\n - `book_ids` (array): Array of book UUIDs to add to the collection\n\n **Response:**\n - `results` (array): Results for each operation\n - `total` (number): Total number of operations\n - `success` (number): Number of successful operations\n - `failed` (number): Number of failed operations\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"operations\": [\n {\n \"collection_id\":\
docs:"## Bulk Remove Books from Collection\n\n Removes multiple books from a collection in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/{collection_id"
docs: "## Bulk Remove Books - Empty List Validation\n\n Tests validation behavior when providing an empty book_ids array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** book_ids array must contain at least one book UUID.\n\n **Purpose:** Ensures the API properly validates input and rejects empty removal requests."
docs:"## Bulk Remove Books - Invalid IDs\n\n Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.\n\n **Expected Result:** 200 OK with partial success\n\n **Purpose:** Verifies that:\n - Invalid UUID formats don't crash the endpoint\n - Non-existent book IDs are handled gracefully\n - Valid IDs in the same request are still processed\n - Response includes detailed results showing which succeeded/failed\n\n **Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately."
docs:"## Bulk Remove Books - Single Book\n\n Tests that bulk remove endpoint works correctly with a single book.\n\n **Expected Result:** 200 OK with removed: 1, total: 1\n\n **Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.\n\n **Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format."
docs:"## Test Collection Rules - Author Contains\n\n Tests the \"contains\" operator on the author field to find books by a specific author (partial match).\n\n **Example Use Case:** Finding all books by an author whose name contains \"Asimov\" (e.g., \"Isaac Asimov\").\n\n **Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).\n\n **Expected Result:** Returns all books where the author field contains \"Asimov\".\n\n **Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names."
docs:"## Test Collection Rules - Copyright Year Greater Than\n\n Tests the \"greater_than\" operator on the copyright_year field to find books published after a specific year.\n\n **Example Use Case:** Creating a \"Modern Books\" collection with books published after 2000.\n\n **Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).\n\n **Field:** `copyright_year` - The year the book was copyrighted/published.\n\n **Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).\n\n **Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era."
docs: "## Test Collection Rules - Empty Rules Array\n\n Tests validation behavior when providing an empty rules array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** rules array must contain at least one rule object.\n\n **Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.\n\n **Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests."
docs:"## Test Collection Rules - No Matches\n\n Tests behavior when collection rules don't match any books in the library.\n\n **Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.\n\n **Expected Result:** 200 OK with empty matches array and total: 0\n\n **Purpose:** Verifies that the API handles zero-match scenarios gracefully:\n - Returns 200 (success) not 404\n - Returns empty array, not null\n - Returns total: 0 for clarity\n - No errors thrown for no results\n\n **Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections."
docs:"## Test Collection Rules\n\n Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/test-rules\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `rules` (array): Array of rule objects to test\n - `field` (string): Field to test (genre, author, copyright_year, tags, etc.)\n - `operator` (string): Comparison operator\n - `equals`: Exact match\n - `contains`: Contains substring (for text fields)\n - `greater_than`: Greater than (for numeric fields)\n - `less_than`: Less than (for numeric fields)\n - `not_equals`: Not equal to\n - `starts_with`: Starts with\n - `ends_with`: Ends with\n - `is_empty`: Field is empty or null\n - `is_not_empty`: Field is not empty and not null\n - `value` (string): Value to compare against (not required\
\ for is_empty/is_not_empty)\n\n **Response:**\n - `matches` (array): Array of matching books\n - `id` (string): Book UUID\n - `title` (string): Book title\n - `author` (string): Book author\n - `genre` (string): Book genre\n - Additional book metadata\n - `total` (number): Total number of matching books\n\n **Status Codes:**\n - 200: Success - returns matching books\n - 400: Invalid request (empty rules array, invalid field/operator)\n - 401: Unauthorized\n - 500: Internal server error\n\n **Example Request:**\n ```json\n {\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
Canonical reference: docs/developer/api/collections/test_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/update_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/collections/update_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
docs:"## Resolve Conflict\n\n Resolves a sync conflict by choosing which source to use for the conflicting data.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/{conflict_id"
Canonical reference: docs/developer/api/conflicts/bulk_dismiss_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/bulk_resolve_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/delete_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/dismiss_all_resolved.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/get_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/list_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/conflicts/resolve_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
docs:"## Bulk Dismiss Conflicts\n\n Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-dismiss\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to dismiss\n\n **Response:**\n - `results` (array): Results for each dismissal\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully dismissed conflicts\n - `failed` (number): Number of failed dismissals\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\"]"
docs:"## Bulk Resolve Conflicts\n\n Resolves multiple sync conflicts in a single request using a specified resolution strategy.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Resolution strategy\n - `most_recent`: Use the most recently updated progress\n - `highest_progress`: Use the reading progress with the highest percent read\n - `server`: Always prefer server-side data\n - `device`: Always prefer device-side data\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n\
\ - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\", \"uuid-3\"],\n \"strategy\": \"most_recent\""
docs:"## Bulk Resolve with Highest Progress Strategy\n\n Resolves multiple sync conflicts using the \"highest_progress\" strategy, which keeps the reading progress with the highest percentage read.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Must be \"highest_progress\"\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been\
\ reading on multiple devices and want to keep the furthest position."
Canonical reference: docs/developer/api/dashboard/get_preferences.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/dashboard/get_sections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
code:"test(\"status must be 200 with valid collection\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response has success message\", function() {\n expect(res.body.message).to.exist;\n});"
docs:"Restore a system collection to its default state.\n\n**Endpoint**: POST /api/dashboard/restore-system-collection\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| collection_name | string | Yes | Name of system collection to restore |\n\nValid collection names:\n- `continue-reading`: Books you're currently reading (0 < progress < 1)\n- `recently-added`: Newly added items to this library\n- `recently-read`: Books you've finished (progress >= 1)\n- `not-started`: Books you haven't read yet (progress = 0 or no record)\n\n## Example Request\n\n```json\n{\n \"collection_name\": \"continue-reading\"\n}\n```\n\n## Response\n\nReturns success message on restore."
Loaded 100 of 860 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.