Vellum/docs/ARCHITECTURE.md
mithral 051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00

316 lines
14 KiB
Markdown

# Vellum — how it works
Vellum is a self-hosted media server. It replaces Jellyfin, Navidrome,
Audiobookshelf, Stump and Kavita with one program: one Go binary, one SQLite
file, one web UI. It handles ebooks, comics, audiobooks, music, photos, games
and video.
This document explains how the pieces fit together and, more importantly, *why*
they fit that way. Where a decision had a real trade-off, there is an ADR in
[`decisions/`](decisions/) with the alternatives that were rejected.
**Size:** ~11,500 lines of Go (plus ~7,000 lines of tests), ~7,100 lines of
frontend, 33 packages, 12 migrations, 64 routes.
---
## 1. The shape
```
one binary one file one UI
┌──────────────────┐ ┌───────────────┐ ┌──────────────┐
│ vellum (Go) │─────▶│ vellum.db │ │ Svelte app │
│ serves API+UI │ │ SQLite + WAL │ │ embedded in │
│ runs the jobs │ └───────────────┘ │ the binary │
└──────────────────┘ └──────────────┘
```
There is no Redis, no message broker, no second process and no CGO. The SQLite
driver is pure Go (`modernc.org/sqlite`), which is why `CGO_ENABLED=0` works and
the container is a bare Alpine image. The job queue is a *table*.
**"One binary" is a deployment choice, not an architecture.** The code is a
modular monolith: 33 packages with enforced boundaries. Splitting it into
services would add network failure modes and make debugging harder, not easier.
See [ADR-004](decisions/ADR-004-modular-monolith.md).
---
## 2. What happens when you open the app
Every request goes through the same chain, in this order:
```
browser
│
├─ withSecurityHeaders outermost, so headers survive a panic
├─ withRecovery panic → 500, never a dead connection
├─ withRequestID X-Request-ID, echoed into every log line
├─ withRequestLogging method, path, status, duration, request_id
└─ auth.Middleware cookie → session → User in the request context
│
└─ handler → auth.VisibleLibraryIDs / CanAccessLibrary → SQL → JSON
```
The **grant check is the security chokepoint**. Every read path goes through it,
including the Jellyfin and Subsonic adapters, which is why an API token can never
see more than the web app can. An item you cannot read returns **404, not 403** —
a 403 would confirm the item exists and leak the shape of someone else's library.
Media bytes take a different path on purpose: `internal/stream` hands off to
`http.ServeContent`, which implements HTTP Range correctly. That is why seeking
works in every player, and it is why compression is scoped to the static UI only
(compressing a Range response would break seeking).
---
## 3. The layers, and the rules that hold them
The code is arranged in six layers. A package may import from a lower layer or
its own, never from a higher one.
| Layer | Packages | What lives here |
|---|---|---|
| 0 | `core`, `httpx`, `natsort`, `arch`, `migrations` | foundation; knows nothing about the app |
| 1 | `auth`, `jobs`, `stream`, `progress`, `probe`, `engine`, `apitoken` | primitives everything is built from |
| 2 | `catalog`, `providers` | the data seam, and third-party API clients |
| 3 | `library`, `scanner`, `media`, `home`, `search`, `metadata`, `playback`, `modules/*`, `*/dto` | the application |
| 4 | `httpapi`, `jellyfin`, `subsonic`, `cli` | adapters: HTTP and foreign protocols |
| 5 | `cmd/vellum` | the only place everything is wired together |
**These rules are enforced by tests, not by documentation.** `internal/arch`
parses every package's imports and fails the build on a violation. There are
seven rules:
1. No upward imports across the layers.
2. `internal/core` may depend only on `migrations`.
3. `internal/catalog` must not know about HTTP — or it stops being reusable by a
job or a test.
4. Modules must not import each other.
5. **Every package must be placed in a layer**, so the map cannot go stale.
6. The set of packages allowed to import `database/sql` is explicit.
7. Packages already moved onto the `catalog.Store` seam must not reach for
`*sql.DB` again.
Each of the seven has been deliberately broken and confirmed to fail. A test that
cannot fail is decoration.
---
## 4. The data model — the decision everything follows from
```
users ──< library_grants >── libraries
│ │
│ └──< media_items ──< media_files
│ │
└──< progress (user, item, file) ───────┘
└──< item_views, favorites, api_tokens
```
**Media items are global. Every per-user thing is a join.**
The `progress` primary key is `(user_id, item_id, file_id)`. That one choice is
why audiobooks needed no new Go module: a twelve-chapter book is one item with
twelve files, and "chapter 7, 4 minutes 32 seconds in" is a single row. It is
also why the Continue shelf is one query, and why per-user state can never
contaminate the shared catalogue.
**Deletion is deliberately not deletion.** A file that vanishes is marked
`missing = 1` with a `missing_since` timestamp, never removed. Deleting cascades
into `progress`, the one thing in the schema that cannot be regenerated — so an
unmounted drive would silently wipe your reading history. There is an explicit
admin purge for when you really mean it. See
[ADR-003](decisions/ADR-003-mark-missing-never-delete.md).
---
## 5. How the library gets in
`internal/scanner` walks the library root and reconciles it with the database.
```
walkRoot → files on disk, keyed by relative path
buildGroups → one item per file, OR one item per directory
batched upsert → 500 groups per transaction
missing pass → mark vanished files, never delete
```
Three things worth knowing:
- **Transactions are chunked.** It used to hold SQLite's single write lock for
the whole library. Now each transaction covers 500 groups, so a progress save
from a playing client succeeds *during* a scan. Measured at 20,000 files with
concurrent writes all returning 200.
- **Change detection is `size` + `mtime`.** Matching files are skipped entirely.
`ffprobe` runs only for changed files, and before the write transaction opens,
so a slow probe can never hold the write lock.
- **Two scans can run at once.** The job queue has four workers, so scanning two
libraries concurrently works — the *writes* interleave at batch granularity
rather than one library blocking the other.
---
## 6. The job queue
`internal/jobs` is a queue implemented as a database table. Claiming a job is a
single atomic statement, so several workers are safe with no extra locking:
```sql
UPDATE jobs SET state = 'running', attempts = attempts + 1
WHERE id = (SELECT id FROM jobs WHERE state = 'queued' AND run_after <= ?
ORDER BY run_after, id LIMIT 1)
RETURNING id, kind, payload, attempts
```
Four workers with jittered idle waits. A panicking handler is recovered and the
job marked failed, so one bad handler cannot kill a worker or strand a row in
`running`. `RecoverStale()` runs at boot and requeues anything a dead process
left behind — without it, a crash mid-scan left a job `running` forever while the
UI polled it forever.
Two job kinds: `scan_library` and `enrich_item`.
---
## 7. Playback — direct play only
**Vellum never transcodes.** The media server is a headless, GPU-less Xeon with
no spare CPU for it. A file either plays in the client or it does not.
This is the single most important thing to understand about the playback code,
and it came from measurement rather than assumption:
| container | video | result |
|---|---|---|
| mp4 | h264 | plays |
| **mkv** | h264 | **plays** — the container is not the problem |
| mov | h264 | plays, even though `canPlayType` says "no" |
| mp4 | **hevc** | **audio only** — a black picture, no error |
| avi | mpeg4 | fails outright |
Two conclusions drive the design:
1. **Codec matters, container barely does.** mkv+h264 plays; mp4+HEVC does not.
Judging a file by its extension is wrong.
2. **Playability is a property of the CLIENT, not the file.** Chrome cannot
decode HEVC; Safari can; Jellyfin's native apps handle far more than either.
So the server **never** declares a file playable. It reports the codec facts
(from `ffprobe`, stored at scan time) and each client decides. See
[ADR-002](decisions/ADR-002-direct-play-only.md).
That is what makes the engine registry work. `internal/engine` holds engines that
declare what they can play — `html5` ships built in; `mpv`, `vlc` and `mdk` are
registered for native clients. Adding one is a declaration, not a rewrite:
```
POST /api/v1/playback/resolve {"item_id":16,"engines":["html5"]}
→ playable=false, reason="vcodec:hevc"
POST /api/v1/playback/resolve {"item_id":16,"engines":["html5","mpv"]}
→ playable=true, engine="mpv"
```
Same file, different answer, because the client offered a different engine.
**The element is always the final authority.** `canPlayType` lies in both
directions, so the player uses it only as a hint and confirms by actually loading
the file and checking `videoWidth`. For HEVC, Chrome plays audio over a blank
frame with no error — the worst possible outcome, and the player detects it and
says so rather than pretending.
---
## 8. Three protocol surfaces over one application
| surface | who uses it | how it authenticates |
|---|---|---|
| `/api/v1/*` | the web app | session cookie |
| Jellyfin (`/Items`, `/Users/…`) | Jellyfin native apps | `X-Emby-Token`, `?api_key=` |
| Subsonic (`/rest/*.view`) | Symfonium, Ultrasonic, play:Sub | `t=md5(key+salt)` |
All three resolve to the same `auth.User` and pass through the same grant check.
`internal/apitoken` is the shared credential layer, and its migration documents a
real asymmetry: Jellyfin tokens are stored **hashed** (like session tokens),
while a Subsonic key must be stored **in the clear**, because verifying `t` means
computing `md5(key + salt)` server-side.
Both adapters are honest about the one thing Vellum cannot do: `PlaybackInfo`
reports `SupportsTranscoding: false`, and a client that demands a transcode gets
`NoCompatibleStream` rather than a URL that would never resolve.
---
## 9. Where things live
```
cmd/vellum wiring and lifecycle — the only place packages meet
internal/
core config, database open, migrations, logger, timestamps
httpx small HTTP helpers (JSON, errors)
auth users, sessions, argon2id hashing, grants
apitoken credentials for non-browser clients
catalog THE data seam: read an item, and check you may
providers Open Library, Google Books, MusicBrainz, ComicVine, IGDB
library library definitions, media-type registry
scanner walk the filesystem and reconcile
probe ffprobe wrapper (optional dependency)
media item, file, cover, progress endpoints
home the five home shelves
search FTS5 search over titles, paths and authors
metadata the enrichment job and its matching policy
playback engine selection endpoint
engine playback engine registry
jobs table-backed job queue
progress per-user playback position
stream Range-capable file serving
modules/ comics, ebooks, games, photos
httpapi mux, middleware, security headers, embedded UI
jellyfin Jellyfin protocol adapter
subsonic Subsonic protocol adapter
arch import-rule tests (no code)
migrations/ numbered SQL, append-only, tracked by user_version
web/ Svelte 5 + Vite frontend
```
---
## 10. Things to know before changing anything
- **`cmd/vellum/main.go` is the only wiring point.** Nothing else should import a
module; modules register themselves.
- **Migrations are append-only.** Never edit an applied file. The FTS5 authors
migration is the cautionary tale: it worked on a fresh database and raised
*"database disk image is malformed"* on an upgrade.
- **All timestamps go through `core.Stamp`.** `RFC3339Nano` trims trailing zeros,
so its strings are not lexicographically sortable — `".5Z" > ".55Z"` while
`.500s < .550s`. That silently broke `ORDER BY` and `MAX()` on the Continue
shelf.
- **Progress is PUT-only**, so `sendBeacon` (POST-only) cannot be used; the
teardown flush uses `fetch` with `keepalive: true`.
- **`canPlayType` is not a source of truth.** Put `src` on the media element, not
`type` on a `<source>` — an explicit type makes Chrome *skip* files it would
otherwise play.
- **If you need an item, take a `catalog.Store`.** Not `*sql.DB`. The seam is
enforced by test rule 6/7.
- **Run `go test ./...` after touching a package's imports** — the arch rules
will tell you immediately if you crossed a layer.
---
## 11. Commands
| Command | What it does |
|---|---|
| `make dev` | run backend and Vite together |
| `go test ./...` | all Go tests, including the arch rules |
| `go vet ./...` | static checks |
| `cd web && pnpm check` | Svelte type-check |
| `vellum user add <name> <pass> [--admin]` | create a user |
| `vellum user passwd <name> <new>` | change a password |
| `vellum user list` | list users |
| `vellum backup <path>` | safe copy of the database (`VACUUM INTO`) |
See [DEPLOY.md](../DEPLOY.md) for deployment, and
[`decisions/`](decisions/) for why things are the way they are.