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. |
||
|---|---|---|
| cmd/vellum | ||
| internal | ||
| migrations | ||
| scripts | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| compose.yml | ||
| DEPLOY.md | ||
| Dockerfile | ||
| entrypoint.sh | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
Vellum
One application, one process, one database for every media type — ebooks, comics, audiobooks, music, photos, games, and video — behind a single account system with real per-user progress and per-library permissions.
A modular monolith. One Go binary, one SQLite file, one web frontend.
The full plan lives in ~/.opencode/plan/vellum.md.
Status
| Phase | Scope | State |
|---|---|---|
| 0 | Repo, config, migrations, health, Docker | done |
| 1 | Users, sessions, library grants | done |
| 2 | Scanner, items, assets | done |
| 2b | FTS5 search | done |
| 3 | EPUB reader + progress | done |
| 4 | CBZ comic reader | done |
| PDF reading (pdf.js) | done | |
| 5 | Job queue, metadata (Open Library, Google Books, MusicBrainz, ComicVine, IGDB) | done |
| 6a | Audiobooks + music playback | done |
| 6c | Photos + thumbnails | done |
| 7 | Games: ROM scanning, platforms, cloud saves, EmulatorJS | done |
| UI | Svelte app: home, browse, item pages, readers | done |
| 6b | Music smart playlists | not started |
| — | Video (stays on Jellyfin) | deferred |
What works today: an administrator adds libraries pointing at server folders, scans them, and optionally enriches metadata. Users sign in and read EPUBs and PDFs, read CBZ comics, listen to audiobooks track-by-track, browse photos, and play emulated games in the browser with their own cloud saves — with position saved per account, per file.
Six media types work: ebooks, comics, audiobooks, music, photos, games. Video is the only one not implemented, and it stays on Jellyfin on purpose.
Of the services in the existing stack, this covers Stump, Kavita, Audiobookshelf, Navidrome, and RomM. Only Jellyfin (video) is left.
Quick start
sudo dnf install golang # once; Go is not in the base image here
make build
make run
curl localhost:7788/healthz # {"database":"ok","status":"ok",...}
Run with media roots configured:
VELLUM_MEDIA_DIRS=/mnt/storage/media/ebooks:/mnt/storage/media/comics make run
Configuration
All configuration is environment variables. Every one has a default, so the binary runs with no configuration at all.
| Variable | Default | Meaning |
|---|---|---|
VELLUM_LISTEN |
:7788 |
Bind address |
VELLUM_DATA_DIR |
./data |
SQLite database lives here |
VELLUM_CONFIG_DIR |
./config |
Regenerable cache (covers, pages) |
VELLUM_MEDIA_DIRS |
(none) | Read-only library roots, colon-separated |
VELLUM_ADMIN_USER |
(none) | Creates the first admin, only on an empty database |
VELLUM_ADMIN_PASSWORD |
(none) | Password for that first admin (min 8 chars) |
VELLUM_COOKIE_SECURE |
false |
Set true when serving over TLS |
VELLUM_COMICVINE_API_KEY |
(none) | Enables comic metadata via ComicVine |
VELLUM_IGDB_CLIENT_ID |
(none) | Enables game metadata via IGDB |
VELLUM_IGDB_CLIENT_SECRET |
(none) | IGDB client secret (both required) |
VELLUM_LOG_LEVEL |
info |
debug, info, warn, error |
VELLUM_LOG_FORMAT |
text |
text or json |
VELLUM_SESSION_TTL |
720h |
Login session lifetime |
VELLUM_SHUTDOWN_GRACE |
15s |
Graceful shutdown timeout |
The first admin is created only when no users exist, so setting these on an established install does nothing. If the database is empty and they are unset, the server logs a warning and refuses to invent a default password.
Layout
cmd/vellum/ main — wiring and lifecycle only
internal/
core/ config, logging, database, migration runner
httpx/ shared JSON helpers
httpapi/ router, middleware, static SPA serving
auth/ argon2id, sessions, grants, bootstrap
library/ library CRUD, media-type registry, scanner
media/ generic item/file/cover/progress endpoints
progress/ the polymorphic per-user progress store
stream/ range-capable file serving
natsort/ natural ordering for pages and tracks
jobs/ table-backed job queue
metadata/ provider interface, Open Library, Google Books, enricher
search/ FTS5 queries
modules/
ebooks/ EPUB parsing and cover extraction
comics/ CBZ page listing and extraction
photos/ thumbnail generation
web/ Svelte app; dist/ is embedded via web/embed.go
migrations/ embedded SQL, applied in order at boot
Module rule: the core never imports a feature module; modules import the core and mount their own routes, tables, and scanner rules. Adding a media type is a new package plus a migration, never a core edit.
Schema rule: media items are global; everything user-specific is a join. Nothing may hold per-user state on a media row.
Database
SQLite in WAL mode, opened through modernc.org/sqlite — a pure-Go driver, so
the binary builds with CGO_ENABLED=0 and cross-compiles without a toolchain
per target.
Migrations are embedded and applied in filename order; applied files are
recorded only by number in PRAGMA user_version, so never edit an applied
migration — add a new one.
Back up by copying the database (or its directory); VACUUM INTO 'backup.db'
takes a consistent snapshot while running.
Deploy
make docker # podman build
podman compose up -d # see compose.yml
The container mounts /data (database), /config (cache), and /media
(read-only). On SELinux hosts every bind mount needs :z — compose.yml
already has it.
The application never writes to /media.
Development
make dev # backend + Vite dev server together, with hot reload
make build # builds the frontend, then the Go binary with it embedded
make build-go # Go only, when the frontend is unchanged
make check # gofmt + vet + test
make run # run the server on :7788
cd web && pnpm dev # frontend only; needs the backend running on :7788
make dev is the one to reach for while developing: it builds and starts the
backend, waits for it to answer, then starts Vite. Vite proxies /api to the
backend so the browser sees a single origin and sessions behave exactly as in
production. Ctrl-C stops both.
If you run Vite on its own, the backend must already be running, or every
request fails with ECONNREFUSED on :7788.