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.