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

14 KiB

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/ 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.


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.


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:

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.

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 for deployment, and decisions/ for why things are the way they are.