Authors are searchable (internal/search, migrations/0009)
- The FTS index covered only title and path, so typing an artist or author
found nothing even though people/item_people already held the credits.
- Rather than a second index, the names are denormalised into a real
media_items.authors column, so the existing external-content FTS and its
triggers keep doing the work; item_people/people triggers recompute it, and
the media_items UPDATE trigger re-indexes automatically.
- Verified: searching "daft punk" now returns Random Access Memories, and so
does the partial "punk".
- The agent found and worked around a real FTS5 external-content bug: on an
UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in
the fresh index, raising "database disk image is malformed". A rebuild before
the backfill fixes it. This would have broken existing databases while a
fresh install looked fine.
gzip for the SPA (internal/httpapi/static.go)
- 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load.
- Scoped to the embedded SPA assets only, never API or media: compressing a
Range response would break seeking, and wrapping every response would undo
Wave 2's Flusher/ReaderFrom forwarding.
- Compresses only when the client accepts gzip, the response is a compressible
type, there is no Range header, and nothing already set Content-Encoding.
Sets Vary: Accept-Encoding and drops the now-wrong Content-Length.
- Verified by sha256 round-trip (identity vs gzip-decompressed are identical),
and by confirming a Range request still returns 206 with no Content-Encoding.
JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller.
epub.js is code-split (Reader.svelte)
- It was a static import, so ~350 KB was downloaded by everyone on first load
even though most never open an ebook. Now loaded on demand.
- Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB).
- Verified at runtime, which pnpm check cannot: only the main bundle loads
initially, the epubjs chunk is fetched when a book is opened, and the reader
still renders.
Home screen tests (internal/media)
- Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading;
the agent measured the real baseline by moving its own files aside.)
- Covers the five shelves, counts, Explore's invariants (visibility, no
duplicates, every kind represented, varies across calls, wrap-around),
the list offset/limit/total contract, MostViewed ordering, and — most
importantly — grant scoping, which is the security chokepoint.
Fixed a bug the tests found: Suggestions seeded from revoked libraries
- topEngagement selected the user's most recent progress row with no library
filter, so progress from a library whose grant had been revoked still steered
the home screen — a visible item was labelled "More books" based on content
the user could no longer read. Returned items were always correctly scoped, so
this was an information-flow gap rather than a content leak.
- Now scoped to visible libraries, and the test that pinned the old behaviour
flipped to assert the correct reason.
gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
A 50k-file library used to wedge the database. This wave fixes the four
reasons why, plus a timestamp-ordering bug the work exposed.
Chunked scans (internal/library/scan.go)
- One transaction wrapped the whole reconciliation, holding SQLite's single
write lock for the entire scan. Now 500 groups per transaction, and the two
missing-file passes are batched too.
- Verified: a 20,000-file scan reports partial progress (scanned=1500 mid-scan)
and a concurrent progress save returns 200 throughout. Under the old code
that save blocked for the whole scan.
- Behaviour change: a scan is no longer all-or-nothing. A crash now leaves a
consistent partial scan. The scan is idempotent, so re-running finishes it.
- res.Errors is capped at 100 (a broken mount used to append one string per
file, then serialise the lot into the job row); the overflow is counted.
Concurrent job workers (internal/jobs/jobs.go)
- Run() was single-threaded, so a scan stalled every enrichment job behind it.
Now 4 workers with jittered idle waits.
- Handlers run concurrently, so Register/lookup take an RWMutex, and a
panicking handler is recovered and marked failed rather than killing a worker
and stranding a row in 'running'.
- RecoverStale() requeues jobs left 'running' by a dead process; called at boot
from main.go. Previously such a job stayed 'running' forever and the UI
polled it forever.
Explore (internal/media/discover.go)
- Full-scanned and sorted the whole library on every home load, and was
deterministic, so it showed the same 30 items every visit. Now a per-kind
fair-share id range scan with a random start, wrap-around, dedup and
interleaving. The doc comment claimed per-kind sampling before; now it does.
Indexes (migrations/0008_query_indexes.sql)
- Benchmarked with ANALYZE over 60k items: browse page 1 13.37ms -> 0.03ms
(484x, full scan -> covering index), deep page 8x, recently added 3.2x.
- A candidate index for the Continue shelf was built, measured and REJECTED:
the planner never chose it because `finished = 0` selects the majority of a
user's rows, so it had no selectivity and would have cost a write on every
progress save. Recorded in the migration rather than shipped as dead weight.
Canonical timestamps (internal/core/stamp.go)
- time.RFC3339Nano trims trailing zeros, so its fraction width varies and the
strings are not lexicographically sortable: ".5Z" > ".55Z" as strings though
.500s < .550s. That broke every ORDER BY <timestamp> and MAX(timestamp),
including the Continue shelf and Most Viewed. It was wrong even in columns
written 100% by Go, so it was not a cross-format issue.
- All 14 write sites now use core.Stamp, matching SQLite's own default format
exactly. Read paths are untouched: RFC3339Nano parses both shapes, so
existing rows keep working.
Also: busy_timeout 5s -> 15s. Chunked scans still delay a concurrent write, and
measured tail latency on a first scan of 15k new files reached 3.3s, leaving
almost no margin at 5s. Waiting beats failing with SQLITE_BUSY and losing a
playback position. A 10ms inter-batch yield was implemented, measured, found to
have no effect distinguishable from noise (overlapping ranges), and removed.
Verified end to end, not just compiled: orphan recovery logged at boot, scan
returns 202 in 0.7ms, Explore varies across loads, concurrent writes survive a
scan. gofmt/vet clean, 14 packages pass, -race clean on the queue, svelte-check
0/0, fresh install applies all 8 migrations.
The landing screen now reads as a product rather than a list.
- migration 0006: item_views, per user. "Most viewed" is personal — what I
re-read is not what you re-read. Counted when an item is opened in a reader,
so the number means consumed, not merely fetched.
- Suggestions: unstarted items related to what the user has been consuming —
same author first, then same kind, then recent as a floor. Each carries the
reason it was chosen ("More by Daft Punk"), because a suggestion nobody can
understand is noise.
- Explore: a mixed sample across every kind, excluding in-progress items so the
zone is discovery rather than a repeat.
- Home fetches all five shelves in one request; the screen never pops in.
- web: Shelf gained subtitle/badge support, Card gained a subtitle, and the
home renders four shelves plus a wrapping Explore grid.
Three bugs found and fixed while verifying:
- Continue showed a multi-track audiobook once per chapter. GROUP BY item_id
with MAX(updated_at) picks one card per item (SQLite takes the bare columns
from the row that produced the max).
- Suggestions and Explore passed their SQL arguments in the wrong order: the IN
list precedes the user id in those queries, so a user id was being bound as a
library id. Covered by a scoping test that would have caught it.
- The sidebar lost its media types on a cold, logged-out load. bootstrap()
fetched media types inside a Promise.all with /auth/me, so the expected 401
rejected the whole thing and nothing refetched after login. Media types are
public and now load independently.