- 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. |
||
|---|---|---|
| cmd/vellum | ||
| internal | ||
| migrations | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| compose.yml | ||
| 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 |
| 5 | Job queue, metadata (Open Library, Google Books) | done |
| 6a | Audiobooks + music playback | done |
| 6c | Photos + thumbnails | done |
| UI | Svelte app | done |
| 6b | Music metadata, smart playlists | not started |
| 7 | Games (EmulatorJS, IGDB) | not started |
| — | Video (stays on Jellyfin for now) | deferred |
What works today: an administrator adds libraries pointing at server folders, scans them, and optionally enriches metadata from Open Library. Users sign in and read EPUBs, read CBZ comics, listen to audiobooks track-by-track, and browse photos — with position saved per account, per file. PDF, RAR comics, games, and video are not implemented.
Five of the six services in the existing stack are covered: Stump, Kavita, Audiobookshelf, Navidrome (playback), and RomM are the intended replacements. RomM is not yet covered; video stays on Jellyfin.
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_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 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 dev server, proxies /api to :7788