Commit graph

12 commits

Author SHA1 Message Date
mithral
2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.

Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.

Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].

Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.

Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.

Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.

Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
2026-10-12 01:41:05 -04:00
mithral
35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00
mithral
ac1bd04df3 Phase 1: one item-access seam, and architecture rules that fail the build
Motivation, from the evidence rather than from taste. Five packages each
implemented "load an item, then check the caller may read it": media, comics,
games, jellyfin (with its own dbItem type and its own SQL), and subsonic. Five
copies of one authorization rule means four can drift, and the failure mode is
not a crash — it is silently serving somebody a library they were never granted.
One implementation is one thing to review.

internal/catalog now owns item and file access, and the access check.

  LoadUnchecked(ctx, db, id)        -- no access check; jobs and tests only
  LoadFor(ctx, db, u, id)           -- load AND enforce the grant, in one call
  Files / FilesFor / FileByID / FirstFile / FileCount

The unchecked variant is deliberately named so that reaching for it by accident
is hard: it is the dangerous call, so it should not have the friendlier name.
This came from the interface-design rule that a good interface makes the right
thing easy and the wrong thing hard.

The security property is tested, not asserted: a forbidden item and an absent
item return byte-identical errors, so no handler can leak the existence of
another user's library through a 403. That is now enforced in one place.

Migrated all five surfaces onto it. media, comics, games and ebooks went from
four duplicate loaders to zero. jellyfin's dbItem and loadDBItem are gone.
subsonic's per-item authorization moved while its album/artist aggregate
projections stayed put, because grouping items into albums is a presentation
concern, not a data-access one. The remaining raw SQL everywhere is browse and
aggregate queries; no per-item load decides access any more.

internal/arch enforces the layering instead of documenting it. Five rules, each
proven able to fail by deliberately breaking it:
  - no upward imports across six explicit layers
  - internal/core may depend only on migrations
  - internal/catalog must not know about HTTP, or it stops being reusable
  - modules must not import each other
  - every package must be placed in a layer, so the map cannot go stale

A test that only ever passes is decoration, so each rule was verified by
introducing the violation and confirming the failure.

Verified: gofmt clean, go build ./... exit 0, go vet exit 0, 20 packages pass,
svelte-check 0/0. End to end against a running server: the vellum API serves
item detail with codec facts, an unknown item 404s, Jellyfin AuthenticateByName
mints a token and /Items browses, and Subsonic ping returns status ok. The
subsonic migration was additionally A/B'd byte-identical against the previous
implementation across ~15 endpoints, and Jellyfin/Subsonic both still return the
same error for a forbidden item as for an absent one.

Known follow-ups, recorded rather than silently skipped:
  - internal/jellyfin has no test files at all. Its wire contract is the most
    client-visible surface in the tree and is now verified only by hand.
  - catalog is item-centric, but Subsonic addresses songs file-first (mf-<id>),
    so it resolves file to item through its own projection. A catalog lookup
    that takes a file id would close that.
  - subsonic loadAlbum now scopes the projection to the item's own library
    rather than all visible audio libraries. Equivalent by construction, since
    an item's kind tracks its library's kind, but a hand-edited mismatch would
    differ.
2026-10-11 15:47:18 -04:00
mithral
785d3ed5ee Waves 4-6: audio depth, ffprobe facts, and direct-play video
Direct play only. The server is a headless GPU-less Xeon: nothing is ever
transcoded or remuxed. A file either plays in the client or it does not.

Measured, not assumed (ffmpeg-generated files with known codecs, played through
Vellum's own stream endpoint in Chrome 155 / Linux):
  mp4  h264+aac   plays        mkv h264+aac  PLAYS   <- container is fine
  mov  h264+aac   plays        webm vp9/opus plays
  mp4  av1+opus   plays        webm vp8/vorbis plays
  mp4  hevc+aac   AUDIO ONLY, videoWidth stays 0, no error
  mkv  hevc+flac  AUDIO ONLY
  avi  mpeg4+mp3  fails outright
All seven audio formats play. Two conclusions drive the design: codec matters
and container barely does (mkv+h264 plays, mp4+HEVC does not), and canPlayType
is unreliable in both directions — it says "no" for mov+h264 (which plays) and
"maybe" for mkv+hevc (whose video never decodes).

Therefore playability is a property of the CLIENT, not the file. The server
never declares a file playable; it reports codec facts and each client decides.
That is what makes adding mpv/libvlc/MDK later a matter of registering an engine
rather than re-architecting.

Wave 4 - audio depth
- New MusicPlayer.svelte: real queue, shuffle that walks a fixed permutation
  (so "previous" means something and nothing repeats early), repeat off/all/one,
  per-track rows, album art, next-track preload.
- AudiobookPlayer: MediaSession (OS media keys + lock screen), playback speed
  that SURVIVES a chapter change and persists, sleep timer with countdown that a
  manual pause cancels, keyboard shortcuts, next-chapter preload.
- Shared mediaSession.ts written by the coordinator before dispatch so both
  players consume one implementation instead of inventing two.

Wave 5 - ffprobe facts
- internal/probe (ffprobe wrapper, optional dependency), migration 0010 adding
  vcodec/acodec/width/height/probed_at to media_files, scanner hook, API fields.
- Probing runs only for NEW or CHANGED files and BEFORE the write transaction
  opens, so it can never hold SQLite's write lock and a rescan never becomes a
  full-library ffprobe sweep. Proven: 797ms probing scan, then 1ms rescan with
  probed_at byte-identical.
- ffprobe skips attached_pic so an mp3 with cover art reports no video codec.
- Fixed a real hole: .avi is in the video registry but had no MIME entry, so it
  was served as application/octet-stream and could never play.

Wave 6 - the video player
- VideoPlayer.svelte with resume, progress + keepalive final flush, MediaSession,
  keyboard shortcuts, multi-file picker, and codec facts shown.
- The honest path is the feature: an HEVC file makes Chrome play audio over a
  blank frame with no error, which is the worst possible outcome. The player
  detects it and says so, naming the codec and pointing at clients that can.

Coordinator fix to Wave 6: the agent's detection hung off `loadedmetadata`, but
an explicit `type` the browser cannot satisfy makes it reject the source during
selection, so NEITHER loadedmetadata NOR error fires — and the player showed a
working-looking 0:00 with a Play button, exactly the silently-broken state the
component exists to prevent. Added a load watchdog: if the element says nothing
within 2.5s, the source was rejected and the honest notice is shown. Verified
per file afterwards (h264/mkv play at 640 wide; hevc and mpeg4 show their
notices).

Verified end to end, not just compiled: 15 packages pass (including the new
internal/probe), gofmt/vet clean, svelte-check 0/0, and every claim above checked
against a running server. Note the demo's own mp3s are 100 KB of random bytes and
cannot decode; the audio players were proven against generated real audio instead.
2026-10-11 14:14:32 -04:00
mithral
09474b0e08 Wave 3: search depth, transfer size, and the Home screen's tests
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.
2026-10-11 13:46:18 -04:00
mithral
c50b3cfb3b Wave 1: survive a real library
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.
2026-10-11 13:10:34 -04:00
mithral
f680e1d9f6 Beta blockers: async scans, admin CLI, deploy docs, pagination, password UI
Five tasks, four delegated to agents on disjoint files and one on the critical
path. Board and verification log: ~/.opencode/plan/vellum-beta-tasks.md

T1 Async scans (coordinator)
- migration 0007 adds ref_id + result to jobs, indexed by (kind, ref_id, id),
  so a job can be found by what it operates on and can report back.
- jobs: EnqueueRef, JobID(ctx) so a handler can attach a result, SetResult.
- POST /libraries/{id}/scan now enqueues and returns 202; a real library takes
  longer than any request timeout, and a hanging request looks like a broken
  server. GET /libraries/{id}/scan/status reports the latest run.
- Libraries.svelte polls and shows "Scanning…" instead of freezing.

T2 Admin CLI (agent)
- internal/cli: `user add|passwd|list`, `backup <path>` via VACUUM INTO, with
  tests. Wired into main.go, which the coordinator kept.

T3 Deploy (agent)
- compose.yml now mounts all six media kinds read-only with :z and carries the
  metadata env keys. DEPLOY.md covers volumes, SELinux, admin bootstrap, TLS,
  backup and upgrade.

T4 Pagination (agent)
- GET /items takes offset and returns total; Browse loads 60 at a time with
  "Load more" and "Showing X of Y" instead of silently truncating at 300.

T5 Password change UI (agent)
- A Password section in Settings with validation, busy and success states.

Verified end to end: scan returns 202 in 0.7 ms and polls to done; the CLI
created a user, changed a password, and produced a complete 240 KB backup while
the server ran; pagination reports total 8 and pages correctly; compose parses
with every mount :z. go vet and 14 test packages pass; svelte-check 0/0.
2026-10-10 18:00:35 -04:00
mithral
4c51482dd6 Home: five sections — Continue, Most Viewed, Suggestions, Recently Added, Explore
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.
2026-10-10 16:59:39 -04:00
mithral
dc95a9f78a Redesign the user-facing experience: home, browse, item pages
The app looked like an admin panel. This is the product people actually see
when they open their library.

- Home: a landing screen with a Continue shelf (progress bars, straight from
  the progress table) and per-type shelves of recent additions. One request
  feeds it, so it does not pop in piece by piece.
- Browse: cover grid per media type with type chips, plus browse-all. Powered
  by a new GET /items that lists across every visible library, filtered by
  kind, through the same access chokepoint.
- Item page: cover, kind, title, authors, progress, a primary action that says
  Continue when you have started, description, and the file list for
  multi-file items. Cards now open the item page rather than jumping straight
  into a reader.
- Search: results grouped by media type, cover-forward.
- Navigation moved from "libraries" to "media types" — you browse Ebooks, not
  "the Ebooks folder". Libraries became an admin concern.
- Design system: dark by default, cover-forward, one accent, tighter type
  scale, horizontal shelves, skeleton loaders.
- media: GET /home, GET /items, and authors on item detail.

Verified by screenshot in a live instance: home shelves, browse grid, and item
page all render correctly.
2026-10-09 21:38:11 -04:00
mithral
1b11867efe Phase 6a: directory grouping, audiobooks and music playback
A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.

- registry: MediaType.Group (file | directory). Audiobooks and music group by
  directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
  their directory disappears, and keeps the file-level added/updated/missing
  accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
  and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
  verifies the file belongs to the item, so an item id cannot be paired with
  another item's file id.

No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.

Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
2026-10-09 21:20:47 -04:00
mithral
c3b7db7114 Phase 4: CBZ comics module and reader
- comics: reads pages straight out of the archive on demand. No extraction
  pass, so a 200-page CBZ is not materialised for a reader who views three
  pages. Pages are read into memory and served via http.ServeContent, which
  gives Range for free.
- natural page ordering: page2 sorts before page10. Lexical ordering shuffles
  a numbered archive, which is the difference between a readable book and a
  scrambled one.
- skips __MACOSX and dotfiles; rejects archives with no images.
- cover is the first page.
- media.Item gained ModTime (from the backing file) so page responses carry a
  Last-Modified and support conditional GETs.
- web: ComicReader with a bounded prefetch window (two pages each way), page
  jump, keyboard nav, and progress stored as a page number.

Verified: 3-page CBZ served in natural order, out-of-range 404, cover from
page 1, progress round-trip. svelte-check 0/0.
2026-10-09 21:07:59 -04:00
mithral
5c63932419 Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/
  If-Modified-Since are correct for every media type by construction
- progress: the polymorphic store. One row per user/item/file; the PK includes
  the file so multi-track audiobooks resume per track later. Finished is
  inferred when position reaches duration.
- media: generic item endpoints (detail, file, cover, progress, recent). Every
  one routes through auth.CanAccessLibrary; an unauthorised item 404s.
- covers: a per-kind extractor registry. Extraction is lazy and recorded in
  the assets table. A missing cover is a placeholder, never a 500.
- modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover
  with three fallback strategies), cover extraction to /config, and lazy
  metadata parsing so parser fixes need no rescan.

Verified against a generated EPUB: title/2 authors/language/publisher/spine,
cover served as PNG, range request returned 206 with correct Content-Range,
progress round-tripped.
2026-10-09 16:36:27 -04:00