Vellum/docs/decisions/ADR-002-direct-play-only.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

102 lines
4 KiB
Markdown

# ADR-002: Direct play only — no transcoding, ever
## Status
Accepted
## Date
2026-10-12
## Context
A media server normally transcodes: it re-encodes a file on the fly when the
client cannot play it. That is how Jellyfin and Plex make an arbitrary library
playable on an arbitrary device.
Vellum runs on a **headless, GPU-less Xeon**. It has no display and no spare CPU
for real-time encoding. A 1080p HEVC transcode on a CPU-only machine runs at a
few times realtime at best; a two-hour film would take tens of minutes, per
viewer, forever.
The question was what to do instead.
## Decision
**Never transcode.** A file either direct-plays in the client or it does not.
The server reports what is *in* the file and the **client** decides whether it
can play it.
## Alternatives Considered
### Transcode on demand, CPU only
- **Pros:** every file plays everywhere.
- **Cons:** on this hardware a single viewer would saturate the CPU for minutes
per film. Two viewers would make it unusable.
- **Rejected:** the hardware cannot support it.
### Remux on demand (`-c copy`), no re-encode
- **Pros:** cheap — no video re-encode, just a container change. Would fix
"mkv will not play".
- **Cons:** the premise turned out to be **false**. Measurement showed Chrome
plays `mkv + h264 + aac` perfectly. The container was never the problem.
- **Rejected:** it solves a problem that does not exist.
### Pre-transcode everything to a normalised format
- **Pros:** paid once, everyone direct-plays forever after.
- **Cons:** doubles disk usage for a whole library, and takes days of CPU for a
large collection. Also irreversible if the settings were wrong.
- **Rejected:** too much cost and risk for the benefit.
## The measurement that decided it
Files with known codecs, played through Vellum's own stream endpoint in Chrome
155 on Linux:
| container | video | audio | result |
|---|---|---|---|
| mp4 | h264 | aac | plays |
| **mkv** | h264 | aac | **plays** |
| mov | h264 | aac | plays |
| webm | vp9 / vp8 | opus / vorbis | plays |
| mp4 | av1 | opus | 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:**
1. **Codec matters, container barely does.** mkv+h264 plays; mp4+HEVC does not.
Judging a file by its extension is wrong.
2. **`canPlayType` is unreliable in both directions.** It says "no" for
`mov+h264` (which plays) and "maybe" for `mkv+hevc` (whose video never
decodes). The only trustworthy test is loading the file and checking
`videoWidth`.
**And the conclusion that shapes the architecture:** 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 a server-side `playable: true/false`
would be wrong for most clients.
## Consequences
- `internal/probe` records codec facts at scan time (optional dependency — the
server works without `ffprobe`, just with less information).
- `internal/engine` holds a registry of playback engines. `html5` ships built in;
`mpv`, `vlc` and `mdk` are registered for native clients. **Adding one is a
declaration, not a re-architecture.**
- `POST /api/v1/playback/resolve` matches a file against the engines the client
declares. The same HEVC file is unplayable with `["html5"]` and playable with
`["html5","mpv"]`.
- The player treats the server as an **advisory** and the media element as the
final authority, because `canPlayType` lies.
- Jellyfin's `PlaybackInfo` reports `SupportsTranscoding: false`, and a client
demanding a transcode gets `NoCompatibleStream` rather than a URL that would
never resolve.
- **The honest UX consequence:** files that cannot direct-play say so clearly,
the same way an unreadable `.mobi` does — rather than a player that loads and
shows nothing. For HEVC, Chrome plays audio over a blank frame with no error,
which is the worst possible outcome, so the player detects exactly that.