<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel><title>BLACK BOX — Hearth Build Log</title><link>https://blog.acarr.org/hearth/</link><atom:link href="https://blog.acarr.org/hearth/feed.xml" rel="self" type="application/rss+xml" /><description>Building Hearth, a self-hosted streaming server for your own media library.</description><language>en-us</language><lastBuildDate>Wed, 16 Sep 2026 12:00:00 +0000</lastBuildDate><item><title>One day, from the first scan to a public front door</title><link>https://blog.acarr.org/posts/one-day-from-scanner-to-front-door/</link><guid isPermaLink="true">https://blog.acarr.org/posts/one-day-from-scanner-to-front-door/</guid><pubDate>Wed, 16 Sep 2026 12:00:00 +0000</pubDate><category>hearth</category><description>Hearth went from an empty folder to a media server with hardware-detected transcoding, TMDB metadata, accounts, and a public hostname in a single sitting. What got built, what broke, and what the numbers say.</description><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>What &quot;play a file&quot; actually means</h2>
<p>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 <em>direct
play</em>, 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.</p>
<p>Hearth probes each file once at scan time (<code>ffprobe</code>, container decided by the
file extension because ffprobe calls every Matroska file &quot;matroska,webm&quot;) 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 <code>ffmpeg</code> process per viewer per title
that runs <em>ahead</em> 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.</p>
<h2>Pacing, or why the encoder should not sprint</h2>
<p>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&#39;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.</p>
<h2>Hardware is a table, not an if-statement</h2>
<p>The box this runs on has an NVIDIA card. The next person&#39;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 <em>encodes a four-second
clip</em> through each candidate and keeps the ones that produce real segments
with keyframes on the six-second boundaries. NVENC needed <code>-forced-idr 1</code>
before it would honour those boundaries at all; the detection caught it, which
is the point of detecting by doing.</p>
<p>The measured difference on the 3080: keeping decoded frames in GPU memory
(<code>-hwaccel_output_format cuda</code>) runs at 287× realtime. Copying them through
system memory: 15×. Same card, one flag.</p>
<h2>Not falling over</h2>
<p>A media server&#39;s worst moment is when the fourth person presses play. Hearth
samples CPU (from <code>os.cpus()</code> deltas, because load average is zero on Windows)
and GPU encoder utilisation (from <code>nvidia-smi</code>), smooths over ten samples, and
refuses a <em>new</em> transcode with <code>503 Retry-After</code> 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
&quot;Resources&quot; page shows the whole thing: sessions, streams, throughput, and
how close to the ceiling the box is.</p>
<p>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.</p>
<h2>Names</h2>
<p>File names are a hostile format. <code>The.X-Files.S03E20.mkv</code>, <code>3x20</code>, <code>Season 03 Episode 20</code> all exist in the same library; the parser handles each and
sorts <em>The X-Files</em> 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.</p>
<p>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.</p>
<h2>The player that does not die</h2>
<p>Picture-in-picture is bound to a <code>&lt;video&gt;</code> 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 <em>position</em> 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.</p>
<h2>Storage, the boring one</h2>
<p>The production box had 7 GB free on its root filesystem and, it turned out,
828 GB unallocated in the same volume group — Ubuntu&#39;s installer had given
root a 100 GB logical volume and stopped. Two online commands later root has
480 GB free and Hearth&#39;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.</p>
<h2>Accounts, and the front door</h2>
<p>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 <code>/setup</code> 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.</p>
<p>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: <em>do setup before you open
the door.</em> Whoever reaches <code>/setup</code> first on a fresh install owns the server,
and a hostname is a very public thing.</p>
<h2>What is next</h2>
<p>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 <code>/api/v1</code> 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.</p>
<p>The whole thing is one repository and one command to run. More when there is
more.</p>]]></content:encoded></item></channel></rss>
