No description
Find a file
mithral 3207b89205 Add a TMDB provider for video: films and television
Five providers existed and none covered the "video" kind, so every movie
and show got a title from its filename and nothing else - no poster, no
plot, no cast, no year. That is the gap between "a folder of videos with a
nice web UI" and a media server.

TMDB keeps films and television in separate namespaces behind separate
endpoints, and a Query does not say which one it is. Rather than guessing
from the title, Search asks both at once and merges by TMDB popularity,
which is the only ranking signal available without a second request per
item.

Speed is the point, so the two requests run concurrently over one derived
context on a single shared client, and whichever fails first cancels the
other: one dead endpoint cannot hold the result hostage while the healthy
one is waited out. Verified red-green - serialising the two fetches makes
TestTMDBSearchesConcurrently fail at 2s.

Two corrections made in review, both found by reading the code rather than
trusting the report:

1. ExternalID was the bare TMDB id, so film 603 and show 603 - different
   works - would have been stored identically in provider_ids. Namespaced
   as movie/603 and tv/603. Nothing resolves an item from an external_id
   today, so this was latent rather than broken, but the table is populated
   as enrichment runs and correcting it later would have needed a data
   migration. Pinned by TestTMDBExternalIDIsNamespaced.

2. Query.Year was added but never populated, so the year filter - the main
   way to tell a remake from the original - never applied. catalog.Item
   already carried Year; the enricher now passes it.

No cover bytes are fetched here: the provider returns image URLs and the
existing enricher caches what it uses. A missing key logs the env var and
skips, so the feature is discoverable from the boot log.

Not verified: the live TMDB API. Tests run against httptest servers only.
2026-10-12 01:17:48 -04:00
cmd/vellum Add a TMDB provider for video: films and television 2026-10-12 01:17:48 -04:00
docs ADR-008: transcoding is optional, hardware-gated, off by default 2026-10-12 01:14:37 -04:00
internal Add a TMDB provider for video: films and television 2026-10-12 01:17:48 -04:00
migrations Waves 9-10: Jellyfin and Subsonic clients can browse and play 2026-10-11 15:05:43 -04:00
scripts dev: fix the Vite proxy and add a one-command dev runner 2026-10-10 16:32:32 -04:00
web Documentation, and make a fresh clone actually work 2026-10-12 00:18:11 -04:00
.dockerignore Phase 0: skeleton, config, migrations, health check, container 2026-10-09 16:26:08 -04:00
.gitignore Phase 2: split the fat packages along real boundaries 2026-10-11 16:03:10 -04:00
compose.yml Beta blockers: async scans, admin CLI, deploy docs, pagination, password UI 2026-10-10 18:00:35 -04:00
DEPLOY.md Beta blockers: async scans, admin CLI, deploy docs, pagination, password UI 2026-10-10 18:00:35 -04:00
Dockerfile Web UI: Svelte app, embedded into the binary 2026-10-09 16:40:58 -04:00
entrypoint.sh Phase 0: skeleton, config, migrations, health check, container 2026-10-09 16:26:08 -04:00
go.mod Advertise Vellum over mDNS so clients find it without a typed URL 2026-10-12 01:04:33 -04:00
go.sum Advertise Vellum over mDNS so clients find it without a typed URL 2026-10-12 01:04:33 -04:00
LICENSE Add MIT license 2026-10-12 00:57:56 -04:00
Makefile Replace the CI workflow with a make gate target 2026-10-12 00:57:56 -04:00
README.md Add a TMDB provider for video: films and television 2026-10-12 01:17:48 -04:00

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 PDF reading (pdf.js) done
5 Job queue, metadata (Open Library, Google Books, MusicBrainz, ComicVine, IGDB, TMDB) 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_DISCOVERY on Set off to stop advertising over mDNS on the LAN
VELLUM_IGDB_CLIENT_ID (none) Enables game metadata via IGDB
VELLUM_IGDB_CLIENT_SECRET (none) IGDB client secret (both required)
VELLUM_TMDB_API_KEY (none) Enables movie and TV metadata via TMDB
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, the one place packages meet
internal/
  core/              config, logging, database, migrations, timestamps
  httpx/             shared JSON helpers
  httpapi/           router, middleware, security headers, static SPA serving
  auth/              argon2id, sessions, grants, bootstrap
  apitoken/          credentials for non-browser clients (Jellyfin, Subsonic)
  catalog/           THE data seam: read an item, and check you may
  library/           library CRUD and the media-type registry
  scanner/           walk the filesystem and reconcile with the database
  probe/             ffprobe wrapper — codec facts (optional dependency)
  media/             generic item/file/cover/progress endpoints
  home/              the five home shelves
  search/            FTS5 queries
  metadata/          the enrichment job and its matching policy
  providers/         Open Library, Google Books, MusicBrainz, ComicVine, IGDB, TMDB
  playback/          playback engine selection endpoint
  engine/            playback engine registry (html5, mpv, vlc, mdk)
  progress/          the polymorphic per-user progress store
  stream/            range-capable file serving
  jobs/              table-backed job queue
  natsort/           natural ordering for pages and tracks
  jellyfin/          Jellyfin protocol adapter (+ dto/)
  subsonic/          Subsonic protocol adapter (+ dto/)
  discovery/         mDNS advertisement on the LAN
  arch/              import-rule tests; holds no code
  modules/
    ebooks/          EPUB parsing and cover extraction
    comics/          CBZ page listing and extraction
    games/           platforms, IGDB, cloud saves, EmulatorJS
    photos/          thumbnail generation
web/                 Svelte app; dist/ is embedded via web/embed.go
migrations/          embedded SQL, applied in order at boot
docs/                architecture guide and decision records

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.

Layer rule: packages are arranged in six layers and a package may never import from a higher one. This is enforced by tests in internal/arch, not by convention — see docs/ARCHITECTURE.md.

Schema rule: media items are global; everything user-specific is a join. Nothing may hold per-user state on a media row.

Documentation

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.