Commit graph

10 commits

Author SHA1 Message Date
mithral
b82090ed29 Phase 7: games — ROM scanning, platforms, cloud saves, EmulatorJS
The RomM replacement.

- registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA,
  N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket,
  WonderSwan, Atari, Coleco, Intellivision, and PlayStation.
- games: platform detection from the file extension, mapping each to an
  EmulatorJS core. Platforms with no browser core are recognised but marked
  not-playable so the UI offers a download instead of a broken player.
- migration 0005: game_saves keyed by (user, item, slot). Save states are user
  data, not media — two people playing the same ROM keep their own progress.
- save endpoints with a 16 MiB cap and a validated slot name; the upload is
  read through a LimitReader so an unbounded body is refused rather than
  buffered.
- igdb provider: Twitch client-credentials flow with the token cached for its
  ~2 month lifetime, Apicalypse query body, and cover URLs normalised from
  protocol-relative t_thumb to https t_cover_big.
- searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before
  searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog"
  matches immediately. Stored titles keep the original.
- web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons.

Verified live against IGDB: Sonic matched (id 316900) with real cover art; a
4 KB save uploaded, listed, downloaded byte-identical, and deleted.
2026-10-09 21:54:49 -04:00
mithral
5a377a2198 PDF reading via pdf.js
- PdfReader renders PDFs to canvas with page navigation and page-number
  progress, reusing the same integer progress model as comics.
- Reader routes by file container, not media kind, so a PDF in a comics
  library and a PDF ebook both open in the PDF reader.
- pdf.js is dynamically imported: the main bundle stays 443 kB and the 474 kB
  pdf chunk plus 1.26 MB worker load only when a PDF is actually opened.
- comics cover extractor now only handles zip archives. A PDF in a comics
  library is a valid item but not a zip, so it returns no cover instead of
  logging an error on every request.
- fixed the page scale to leave slack for the vertical scrollbar, which was
  otherwise forcing a horizontal scrollbar on tall pages.

Verified live: a 3-page PDF renders, navigates, and reports 33% progress.
2026-10-09 21:43:43 -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
cebe1977e4 Web: per-user library grants UI
Multi-user was the central design decision and grants were API-only, which
made the whole thing unusable from the browser.

- Users screen lists each account with an expandable library panel.
- Read and Download checkboxes per library, loaded lazily per user.
- Clearing both flags deletes the grant, so "no grant" and "granted nothing"
  stay the same state rather than two representations of the same thing.
- Admins show a note instead of checkboxes: they read every library by design.

Verified end to end: granted library visible and its items readable, ungranted
library 404s on both item access and search, revoke takes effect immediately.
2026-10-09 21:24:04 -04:00
mithral
0212d089ec Phase 6c: photo thumbnails and viewer
- photos: generates a downscaled JPEG thumbnail per image, because serving a
  folder of 12-megapixel originals to a grid is unusable on a TV box. Uses
  CatmullRom scaling via golang.org/x/image; decodes jpeg/png/gif/webp.
- an undecodable format returns no thumbnail rather than an error, so the grid
  falls back to a placeholder.
- images already smaller than the 512px ceiling are passed through untouched.
- library items endpoint now returns library_id, which the viewer needs to
  find its siblings for navigation.
- web: PhotoViewer with keyboard and button navigation within the library.

Verified: 900x1200 PNG thumbnailed to a 512-long-edge JPEG and served;
unit tests assert exact dimensions, aspect preservation, and that non-images
produce no thumbnail without erroring.
2026-10-09 21:22:30 -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
df740d3e1a Phase 5: job queue and metadata providers
- jobs: a table-backed queue in the same SQLite file. No Redis, no second
  process, included in the same backup. claim() is a single UPDATE...RETURNING
  so a job cannot run twice even with several workers. Failures back off
  exponentially; a missing handler fails permanently rather than retrying a
  bug.
- metadata: Provider interface plus Open Library and Google Books (both
  keyless). The enricher tries providers in registration order, falls back on
  empty, and continues past a provider error.
- migration 0004: metadata_fetch records the attempt, not just the match, so a
  rescan does not re-hammer a rate-limited API for an item that will never
  match. Errors are always retried; ok/notfound are cached for 30 days.
- enrichment writes provider_ids, updates title/year, links authors through
  people/item_people, and downloads the cover as an asset. The cover download
  runs outside the transaction: a slow image host must not hold a write lock.
- endpoints: queue enrichment per library, and inspect an item's matches.
- web: Enrich button per library.

Verified live against Open Library: "The Hobbit" matched /works/OL27482W,
title and year 1937 written, a 301x500 JPEG cover downloaded and served.
2026-10-09 21:16:07 -04:00
mithral
a2c2f7a7c1 Phase 2b: FTS5 search
- migration 0003: an FTS5 index over media_items as an external-content table,
  so the index stores no copy of the text. Triggers keep it in sync for every
  write path, including the scanner's bulk inserts.
- migrate: splitStatements now tracks BEGIN...END depth, so CREATE TRIGGER
  bodies survive as one statement. Without this, the trigger's internal
  semicolons would tear it apart.
- search: every whitespace term is quoted before it reaches FTS5. Unquoted
  input like a bare quote, a hyphen, or the word OR is valid FTS5 syntax and
  would either error or silently change the query's meaning. Tested against a
  list of hostile inputs, including a DROP TABLE attempt.
- scoped through the same VisibleLibraryIDs chokepoint; an empty library set
  returns nothing, never everything.
- web: search box in the topbar and a results view.

Verified live: finds items across libraries, hostile input returns 200 with no
results, index stays in sync on insert/update/delete.
2026-10-09 21:10:44 -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
5283e073f5 Web UI: Svelte app, embedded into the binary
The frontend the app needs to be usable at all.

- web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav,
  library list/create/scan/delete, item grid with covers, admin user
  management, and an epub.js reader that syncs progress.
- Reader resumes by spine index — an integer — which is why the polymorphic
  progress table needs no text column.
- Session state uses classic stores in a plain .ts module rather than runes in
  a .svelte.ts module, avoiding special module resolution.
- Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the
  directive valid before the frontend is built, so `go build ./...` never
  fails. Unknown non-API paths serve index.html for client routes; /api/* and
  /healthz always return JSON and never fall back to HTML.
- Dockerfile builds the UI in a node stage and overlays dist into the Go stage.
- pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two
  unused transitive scripts explicitly denied so install exits 0.

svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route
fallback, hashed asset, and JSON 404 on /api/*.
2026-10-09 16:40:58 -04:00