Build log

One day, from the first scan to a public front door

Hearth is a media server for files you already own. Not a catalogue, not a store: a folder of video on a NAS, a browser, and the shortest honest path between the two. Plex and Jellyfin do this well. Hearth exists because I wanted to understand every piece of that path, and because the pieces that matter turned out to be small enough to own.

This is the first entry, so it covers the whole first day. Twenty-seven commits, about 9,500 lines of TypeScript, 83 tests, and one library of 12,034 files that has been playing since the afternoon.

What "play a file" actually means

A browser will play an MP4 with H.264 video and AAC audio straight off the disk. Hand it a range-capable URL and it seeks by itself. That is direct play, and it costs the server nothing but bandwidth. Everything else — MKV containers, HEVC, DTS audio, ten-bit colour — has to be re-encoded on the fly into something the browser accepts. That is the whole game: decide which case you are in, and make the second case not hurt.

Hearth probes each file once at scan time (ffprobe, container decided by the file extension because ffprobe calls every Matroska file "matroska,webm") and stores the verdict. Direct-play files are served with proper HTTP range support. The rest go through an HLS pipeline: a playlist built from the known duration, and segments produced by one ffmpeg process per viewer per title that runs ahead of the playhead, not on demand per segment. The per-segment version was the first thing built and the first thing thrown away — every segment started its own encoder and the timestamps never lined up.

Pacing, or why the encoder should not sprint

An encoder that runs flat out finishes a two-hour film in ten minutes on a GPU and then sits there, having burned a slot on the card for a viewer who is eight minutes in. Hearth uses ffmpeg's read-rate control: sixty seconds of video at full speed to build a cushion, then one-and-a-half times realtime for the rest. Scrubbing forward restarts the encoder at the new position. The cushion means a stall on the network does not become a stall on screen.

Hardware is a table, not an if-statement

The box this runs on has an NVIDIA card. The next person's will not. Rather than bake NVENC in, each encoder backend is a row of data — the input flags, the scale filter, the encoder name and quality mapping — for NVENC, Intel QuickSync, VAAPI, AMD AMF, Apple VideoToolbox, and plain x264. At startup Hearth does not ask the driver what it supports; it encodes a four-second clip through each candidate and keeps the ones that produce real segments with keyframes on the six-second boundaries. NVENC needed -forced-idr 1 before it would honour those boundaries at all; the detection caught it, which is the point of detecting by doing.

The measured difference on the 3080: keeping decoded frames in GPU memory (-hwaccel_output_format cuda) runs at 287× realtime. Copying them through system memory: 15×. Same card, one flag.

Not falling over

A media server's worst moment is when the fourth person presses play. Hearth samples CPU (from os.cpus() deltas, because load average is zero on Windows) and GPU encoder utilisation (from nvidia-smi), smooths over ten samples, and refuses a new transcode with 503 Retry-After past a configurable load limit. Its own background work — scanning, pre-converting — is exempt from that refusal, and drops to a single worker the moment anyone is watching. A "Resources" page shows the whole thing: sessions, streams, throughput, and how close to the ceiling the box is.

There are presets for the two ends of the world, a two-core NAS and a workstation with a discrete GPU, with detection suggesting the right one.

Names

File names are a hostile format. The.X-Files.S03E20.mkv, 3x20, Season 03 Episode 20 all exist in the same library; the parser handles each and sorts The X-Files under X the way a library would. Once a show is recognised it is matched against TMDB — the open database, since IMDb has no public API — for real titles, synopses, art, and per-episode names. Matching is a pure function with tests: year agreement, title similarity, vote count as a tiebreak. Misses can be fixed by hand from a metadata editor, and the fix is remembered as manual so a re-run does not undo it.

Across five libraries the auto-match rate lands around 85%. The misses are mostly what you would expect: loose files with no series folder and names that are only names to their owner.

The player that does not die

Picture-in-picture is bound to a <video> element. Navigate to another page in a normal React app and the element is destroyed, so the PiP window closes. Hearth has exactly one video element, mounted in the root layout, that never leaves the DOM. Pages position it — over a slot on the watch page, in a corner dock elsewhere — and hand it what to play. Episodes have previous/next buttons under the player and advance on their own when one ends; the hand-over is a client-side navigation, so the element survives it.

Storage, the boring one

The production box had 7 GB free on its root filesystem and, it turned out, 828 GB unallocated in the same volume group — Ubuntu's installer had given root a 100 GB logical volume and stopped. Two online commands later root has 480 GB free and Hearth's state (database, posters, art cache, transcode segments) lives on its own 300 GB volume, selected with one environment variable. Pre-converted copies of files that can never direct-play — x264 slow, roughly half the size of the originals at the same quality — go to a network share, which is the one place they belong.

Accounts, and the front door

Until this evening the server trusted everyone. Now: usernames and scrypt-hashed passwords; sessions as database rows named by an HMAC-signed cookie, so signing out or disabling someone bites immediately; a request proxy that turns away anything without a valid signature before it reaches a route. The first account, made at /setup on a fresh install, is the administrator. Admins mint invite links; the invitee chooses their own password, so no password ever passes through the admin.

With that in place Hearth got a public hostname: TLS terminated at an edge box, a WireGuard tunnel home, and a rule on the server that only accepts the tunnel and the LAN. The one lesson from that step: do setup before you open the door. Whoever reaches /setup first on a fresh install owns the server, and a hostname is a very public thing.

What is next

Subtitles and audio-track selection. Trick-play thumbnails on the scrub bar. A proper migration tool before the first schema change that is not additive. A /api/v1 for the mobile and TV apps, whose design is written and waiting. And the pre-conversion batch, which at 0.57 TB is a decision, not a click.

The whole thing is one repository and one command to run. More when there is more.