<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-AU"><generator uri="https://jekyllrb.com/" version="4.0.1">Jekyll</generator><link href="https://andrew-snape.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://andrew-snape.github.io/" rel="alternate" type="text/html" hreflang="en-AU" /><updated>2026-09-11T10:02:13+10:00</updated><id>https://andrew-snape.github.io/feed.xml</id><title type="html">Andrew Snape</title><subtitle>A coding blog about running a self-hosted homelab: NAS, Docker, the *arrs, Plex, Immich, File Flows, and Homebridge.</subtitle><entry><title type="html">Edu Links: A Home for the Resources I Kept Losing Between Units</title><link href="https://andrew-snape.github.io/2026/08/27/edu-links-teaching-resource-hub/" rel="alternate" type="text/html" title="Edu Links: A Home for the Resources I Kept Losing Between Units" /><published>2026-08-27T00:00:00+10:00</published><updated>2026-08-27T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/27/edu-links-teaching-resource-hub</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/27/edu-links-teaching-resource-hub/"><![CDATA[<p>There’s no shortage of good education sites out there – inquiry blogs,
subject hubs, official IB resource centres, worksheet generators, planning
frameworks – and there are more of them every year. Finding a good resource
was never the problem. Keeping track of the ones I’d already found was.</p>

<p>You bookmark something brilliant mid-unit, move on, and by the time you’d
actually use it again the unit’s finished, the term’s changed, or you’ve
moved year levels entirely and it’s sitting three folders deep in a browser
bookmarks bar that’s really just a graveyard sorted by the order you found
things in. The resource never went anywhere. Your ability to find it again
did. So I built <a href="https://andrew-snape.github.io/edsites/">Edu Links</a>: a
small, static site that’s nothing but curated links, organised by subject,
searchable, and boring enough on purpose that I’ll actually keep it current.</p>

<h2 id="what-it-actually-is">What it actually is</h2>

<p>Not a bookmarking app, not a CMS, nothing to log into. It’s a Jekyll site –
one YAML file (<code class="highlighter-rouge">_data/links.yml</code>) holding every link, grouped by subject and
then by sub-area, rendered as cards with a logo, a name, and a one-line
description of <em>why</em> it’s useful rather than just what it is. Right now
that’s 101 links across six subjects – Maths, Literacy, Class Utilities,
Research, PYP, and Christian Studies – split into 30 sub-areas like
<code class="highlighter-rouge">fluency_and_number_facts</code> and <code class="highlighter-rouge">atl_skills</code>.</p>

<p>Adding a link means editing one file: a <code class="highlighter-rouge">name</code>, a <code class="highlighter-rouge">url</code>, a <code class="highlighter-rouge">description</code>
under the right subject and sub-area, commit, and GitHub Actions rebuilds
the site. No admin panel, no database, nothing else to think about.</p>

<h2 id="the-small-stuff-that-took-longer-than-the-big-stuff">The small stuff that took longer than the big stuff</h2>

<p>Card logos are fetched automatically from each site’s favicon – one less
thing to source and upload by hand. But favicons fail: a 404, a school
network’s content filter blocking the request, whatever. An <code class="highlighter-rouge">onerror</code>
handler on the image quietly drops it and falls back to a letter tile
instead of a broken-image icon. Simple enough, until two failed favicons
that happen to share an initial letter render as identical tiles, and a page
full of “these look like copies of each other” is worse than no logos at
all. The fix isn’t randomness – that would make cards flicker between
colours on every rebuild – it’s a cheap deterministic hash of the link’s
name and domain, spread across a small palette, so the same link always
lands on the same tile colour but a different link with the same initial
lands on a different one.</p>

<p>Headings needed their own small fix too. Sub-area keys are <code class="highlighter-rouge">snake_case</code>
(<code class="highlighter-rouge">atl_skills</code>, <code class="highlighter-rouge">inquiry_and_planning</code>), turned into display headings
automatically – which is fine until an acronym runs through the same
title-casing logic and “ATL” becomes “Atl”. An <code class="highlighter-rouge">acronyms</code> list in
<code class="highlighter-rouge">_config.yml</code> (<code class="highlighter-rouge">ATL</code>, <code class="highlighter-rouge">PYP</code>, <code class="highlighter-rouge">IB</code>, <code class="highlighter-rouge">STEM</code>, <code class="highlighter-rouge">STEAM</code>, <code class="highlighter-rouge">ICT</code>, <code class="highlighter-rouge">BTN</code>, <code class="highlighter-rouge">PZ</code>) keeps
those fixed, matched on whole words only, so “atlas” doesn’t get mangled
into “ATLas” along the way.</p>

<h2 id="search-without-needing-a-server">Search, without needing a server</h2>

<p>Every link is also written into a generated <code class="highlighter-rouge">search.json</code> at build time, and
a small vanilla-JS script searches it client-side – press <kbd>/</kbd> from
anywhere on the site, the way most docs sites work, and it searches every
subject at once rather than just the page you’re on. It’s progressive
enhancement on purpose: if the script fails to load for any reason, the site
still works exactly as well as it did before JavaScript existed, you just
browse by page instead of searching.</p>

<h2 id="built-over-a-weekend">Built over a weekend</h2>

<p>Scaffolded Saturday, real content and the search/UI pass on Sunday – empty
repo to a live, searchable site in a weekend, which is about the right level
of ambition for a resource hub. It doesn’t need to be clever. It needs to
still be useful next term, and the term after that, once I’ve completely
forgotten I ever found half of what’s on it.</p>

<p>Repo’s at <a href="https://github.com/andrew-snape/edsites">andrew-snape/edsites</a> if
you want to see how it’s put together, or steal the favicon-fallback trick
for a link dump of your own.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="projects" /><category term="education" /><summary type="html"><![CDATA[There’s no shortage of good education sites out there – inquiry blogs, subject hubs, official IB resource centres, worksheet generators, planning frameworks – and there are more of them every year. Finding a good resource was never the problem. Keeping track of the ones I’d already found was.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/edu-links-teaching-resource-hub.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/edu-links-teaching-resource-hub.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Audiobooks and Music: The Messy Half of the Media Stack</title><link href="https://andrew-snape.github.io/2026/08/14/audiobooks-and-music-on-the-nas/" rel="alternate" type="text/html" title="Audiobooks and Music: The Messy Half of the Media Stack" /><published>2026-08-14T00:00:00+10:00</published><updated>2026-08-14T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/14/audiobooks-and-music-on-the-nas</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/14/audiobooks-and-music-on-the-nas/"><![CDATA[<p>Video is a solved problem. Sonarr and Radarr find things, Plex plays them,
and most weeks I do not think about it. Music and audiobooks are the other
half of the stack, and they are messier in a way that turns out to be more
interesting to write about: the automation is thinner, the metadata is
worse, and the files arrive named like a ransom note.</p>

<p>Here is how that half is put together.</p>

<h2 id="music-lidarr-slskd-and-soularr">Music: Lidarr, slskd and Soularr</h2>

<p>Lidarr handles the library and knows what is missing. What it is not
especially good at is <em>finding</em> things, at least not the older, more obscure
and more Australian end of what I listen to. Usenet and torrent indexers
tend to carry the big releases and not much else.</p>

<p>Soulseek covers that gap better than anything else I have tried, because it
is people sharing their actual collections rather than an index of scene
releases. The problem is that Soulseek is a chat-era desktop client, not
something you point a *arr at.</p>

<p>Three containers solve that:</p>

<ul>
  <li><strong><a href="https://github.com/slskd/slskd">slskd</a></strong> is a headless Soulseek daemon
with a web UI and, crucially, an API.</li>
  <li><strong><a href="https://github.com/mrusse/soularr">Soularr</a></strong> is the glue. It polls
Lidarr for missing albums, searches slskd for them, downloads what
matches, and hands the result back to Lidarr to import.</li>
  <li><strong>Lidarr</strong> itself, on the <code class="highlighter-rouge">nightly</code> tag, because the music side of the
*arr family moves slower and the stable builds lag further behind than
they do for TV and film.</li>
</ul>

<p>Soularr runs on a five minute loop:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">environment</span><span class="pi">:</span>
  <span class="na">SCRIPT_INTERVAL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">300"</span>
</code></pre></div></div>

<h2 id="the-settings-that-actually-mattered">The settings that actually mattered</h2>

<p>Most of Soularr’s config is fine at defaults. Three lines were not.</p>

<p>The first is format. I originally allowed MP3 as a fallback and regretted
it, because “available” and “worth keeping” are different questions and MP3
kept winning on availability:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># FLAC preferred, AAC (.m4a/.aac) as fallback. No MP3.
</span><span class="py">allowed_filetypes</span> <span class="p">=</span> <span class="s">flac 24/192,flac 16/44.1,flac,m4a,aac</span>
</code></pre></div></div>

<p>Dropping MP3 entirely means some albums simply do not get grabbed. That is
the correct outcome. They sit in Lidarr as missing until someone shares a
better copy, which on Soulseek happens more often than you would expect.</p>

<p>The second is match strictness. Soulseek filenames are whatever the sharer
felt like that day, so a loose match will cheerfully import a live bootleg
as the studio album:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">minimum_filename_match_ratio</span> <span class="p">=</span> <span class="s">0.8</span>
<span class="py">failed_import_denylist</span> <span class="p">=</span> <span class="s">True</span>
</code></pre></div></div>

<p>The denylist is the important half. Without it, a release that fails to
import gets found again on the next pass, fails again, and loops forever.</p>

<p>The third is the download path, and it is the whole point of
<a href="/2026/08/14/one-docker-mount-for-the-arrs/">the post about the single mount</a>:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Same folder as before, but reached via Lidarr's /data mount so it sits on the
# SAME filesystem as the root folder (/data/Music) -&gt; instant hardlink import.
</span><span class="py">download_dir</span> <span class="p">=</span> <span class="s">/data/downloads/complete/slskd</span>
</code></pre></div></div>

<p>Same folder on disk, expressed through Lidarr’s <code class="highlighter-rouge">/data</code> mount rather than a
separate one, which turns the import from a copy into a hardlink.</p>

<h2 id="the-alac-era-and-why-it-ended">The ALAC era, and why it ended</h2>

<p>Digging through the Lidarr config folder to write this, I found the previous
version of my answer to the format question, and it was the exact opposite
one. <code class="highlighter-rouge">extended.conf</code> still says:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>audioFormat="alac"    # Set this to ALAC (Apple Lossless Audio Codec)
requireQuality="true"  # Enforces ALAC and lossless format during conversion
</code></pre></div></div>

<p>alongside three shell scripts whose entire job was to walk the library,
convert every FLAC to ALAC with ffmpeg, and delete the original.</p>

<p>The reasoning was sound at the time. This is an all-Apple house, and for
years iOS simply would not play FLAC. ALAC is also lossless, so converting
between the two costs nothing in quality, and it meant everything played
everywhere without thinking about it.</p>

<p>What changed is that the problem went away. Modern iOS handles FLAC, and
Plex and Audiobookshelf both transcode on the fly anyway. Rewriting the
entire library to solve a compatibility problem that no longer exists is
pure churn, so now Soularr fetches FLAC and it stays FLAC.</p>

<p>The scripts are worth keeping around as a warning, though. Here is the
business end of the newer one:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find <span class="nb">.</span> <span class="nt">-type</span> d <span class="nt">-name</span> <span class="s2">"@eaDir"</span> <span class="nt">-prune</span> <span class="nt">-o</span> <span class="nt">-type</span> f <span class="nt">-iname</span> <span class="s2">"*.flac"</span> <span class="nt">-print0</span> | <span class="se">\</span>
  xargs <span class="nt">-0</span> <span class="nt">-P</span> 4 <span class="nt">-I</span> <span class="o">{}</span> bash <span class="nt">-c</span> <span class="s1">'
    ...
    if ffmpeg -nostdin -y -i "$file" -vn -acodec alac "$output"; then
      rm "$file"
    ...
</span></code></pre></div></div>

<p>Four parallel ffmpeg jobs, each deleting the source the moment the encode
returns zero, across the whole music library, with no dry run and no way to
undo it. Compare that to the audiobook script further down this post, which
defaults to printing what it would do and moves duplicates aside rather than
deleting them. Same author, same NAS, about eighteen months apart. The
difference is entirely that in between I had a script do something I did not
expect on a folder I cared about.</p>

<p>Those files are still sitting in the config folder. That is fine while they
are only ever run by hand, but “a destructive script with no dry run, left
lying about” is exactly the sort of thing that is fine right up until it is
not.</p>

<h2 id="sharing-back">Sharing back</h2>

<p>One thing worth saying plainly, because it is easy to set up slskd as a
pure leech and never think about it. Soulseek is a community of people
sharing personal collections, and the whole thing falls over if everyone
takes and nobody gives. My music library goes back in, read-only:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">/volume1/Music:/music:ro</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">shares</span><span class="pi">:</span>
  <span class="na">directories</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">/music</span>
</code></pre></div></div>

<p>The <code class="highlighter-rouge">:ro</code> is deliberate. slskd has no reason to write to the library, so it
cannot.</p>

<h2 id="audiobooks-and-ebooks">Audiobooks and ebooks</h2>

<p>Different problem entirely. There is no Sonarr for audiobooks, so this side
is less “automation” and more “a good pipeline with a human at the front”.</p>

<p><strong><a href="https://www.audiobookshelf.org">Audiobookshelf</a></strong> is the server. It
handles audiobooks, ebooks and podcasts, remembers where everyone is up to,
and has decent iOS apps, which matters in this house. One small deployment
note: it defaults to port 80, which is a bad neighbour on a Synology, so it
gets moved:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">environment</span><span class="pi">:</span>
  <span class="na">PORT</span><span class="pi">:</span> <span class="m">10000</span>   <span class="c1"># override the default port 80</span>
<span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">/volume1/AudioBooks:/audiobooks</span>
  <span class="pi">-</span> <span class="s">/volume1/Books:/Books</span>
  <span class="pi">-</span> <span class="s">/volume1/Podcasts:/podcasts</span>
</code></pre></div></div>

<p><strong>Calibre-Web-Automated</strong> handles ebooks. The useful part over plain
Calibre-Web is the ingest folder: drop a file into a watched directory and
it converts, tags, covers and files it into the library without opening the
Calibre desktop app.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="s">/volume1/downloads/book-ingest:/cwa-book-ingest</span>   <span class="c1"># clean drop zone CWA watches</span>
<span class="pi">-</span> <span class="s">/volume1/Books:/calibre-library</span>                   <span class="c1"># final ebook library</span>
</code></pre></div></div>

<p><strong>Shelfmark</strong> sits in front of both as the request tool: search, pick,
download. The interesting bit is that it routes by type. Ebooks go to the
CWA ingest folder so Calibre-Web-Automated processes them. Audiobooks go
straight into the Audiobookshelf library, because there is nothing to
convert:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="s">INGEST_DIR=/cwa-book-ingest</span>
<span class="pi">-</span> <span class="s">DESTINATION_AUDIOBOOK=/audiobooks</span>
<span class="pi">-</span> <span class="s">HARDLINK_TORRENTS_AUDIOBOOK=false</span>
</code></pre></div></div>

<p>That last line is a deliberate exception to everything I argued above.
Audiobook torrents routinely arrive as multi-part RAR sets rather than
playable files, so there is nothing sensible to hardlink. Copy and extract
is the correct behaviour here, even though it costs the disk space.</p>

<h2 id="the-part-no-tool-fixes">The part no tool fixes</h2>

<p>Audiobooks arrive named appallingly. Some are a bare author folder. Some are
<code class="highlighter-rouge">Richard_Powers_-_The_Overstory__Unabr_-_64k__2018____01_23__-__The_Overstory.nfo</code>.
Some are a single folder containing four unrelated Roald Dahl books. No
scanner recovers from that, so I wrote two scripts.</p>

<p><code class="highlighter-rouge">inventory-audiobooks.sh</code> is read-only and changes nothing. It walks the
library and writes one text file listing every folder, every audio file, and
the embedded tags from the first file in each folder via <code class="highlighter-rouge">ffprobe</code>. That
inventory is what you actually plan from.</p>

<p><code class="highlighter-rouge">tidy-audiobooks.sh</code> does the work, and its most important line is at the
top:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># SAFE BY DEFAULT: DRY=1 only prints what it would do.</span>
<span class="nv">DRY</span><span class="o">=</span>1
</code></pre></div></div>

<p>Every operation goes through one wrapper so the dry run is not something I
have to remember to implement per command:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>act<span class="o">(){</span> <span class="nb">echo</span> <span class="s2">"+ </span><span class="nv">$*</span><span class="s2">"</span><span class="p">;</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$DRY</span><span class="s2">"</span> <span class="nt">-eq</span> 0 <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>
</code></pre></div></div>

<p>The target layout is <code class="highlighter-rouge">Author - Title</code>, or <code class="highlighter-rouge">Author - Series ## - Title</code> for
anything in a series, zero-padded so it sorts properly. Duplicates and
GraphicAudio versions get moved to a <code class="highlighter-rouge">_duplicates</code> folder rather than
deleted, because a script that has never made a mistake has simply not run
often enough yet.</p>

<p>The script is also, deliberately, a hand-written list of decisions rather
than clever pattern matching:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>act <span class="nb">mv</span> <span class="s2">"</span><span class="nv">$LIB</span><span class="s2">/Hanya Yanagihara"</span>          <span class="s2">"</span><span class="nv">$LIB</span><span class="s2">/Hanya Yanagihara - A Little Life"</span>
act <span class="nb">mv</span> <span class="s2">"</span><span class="nv">$LIB</span><span class="s2">/Douglas Stuart"</span>            <span class="s2">"</span><span class="nv">$LIB</span><span class="s2">/Douglas Stuart - John of John"</span>
</code></pre></div></div>

<p>That looks like a lot of typing, and it is. But it means the script doubles
as a record of every judgement call I made about the library, which a regex
never would.</p>

<h2 id="what-it-adds-up-to">What it adds up to</h2>

<p>Plex also mounts <code class="highlighter-rouge">/volume1/AudioBooks</code>, so anything in there turns up on the
Apple TVs and HomePods alongside everything else. Audiobookshelf handles the
phones. Both read the same tidied folders, which is the entire reason the
tidying was worth doing.</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="audiobooks" /><category term="music" /><summary type="html"><![CDATA[Video is a solved problem. Sonarr and Radarr find things, Plex plays them, and most weeks I do not think about it. Music and audiobooks are the other half of the stack, and they are messier in a way that turns out to be more interesting to write about: the automation is thinner, the metadata is worse, and the files arrive named like a ransom note.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/audiobooks-and-music-on-the-nas.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/audiobooks-and-music-on-the-nas.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Immich: Photos Alongside iCloud, and Slowly Instead of It</title><link href="https://andrew-snape.github.io/2026/08/14/immich-alongside-and-instead-of-icloud/" rel="alternate" type="text/html" title="Immich: Photos Alongside iCloud, and Slowly Instead of It" /><published>2026-08-14T00:00:00+10:00</published><updated>2026-08-14T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/14/immich-alongside-and-instead-of-icloud</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/14/immich-alongside-and-instead-of-icloud/"><![CDATA[<p>This is an all-Apple house, so iCloud Photos is the path of least
resistance and it works well. That is precisely the problem. It is the one
part of the setup where the only copy of something irreplaceable lives
somewhere I do not control, on a subscription that gets renewed forever
because the alternative is deciding which photos to delete.</p>

<p><a href="https://immich.app">Immich</a> is the fix, and it has taken over in two
stages: first as an additional copy running alongside iCloud, and gradually
as the place the photos actually live.</p>

<h2 id="why-immich-and-not-just-a-folder-of-files">Why Immich and not just a folder of files</h2>

<p>A folder of files is a backup. It is not a photo library. Nobody in the
family is going to browse a share looking for the photo of the dog at the
beach.</p>

<p>Immich is close enough to Photos that people will actually use it: a proper
mobile app that backs up in the background, albums, faces, and search that
understands what is in the picture rather than just the filename. That last
part matters more than I expected. Being able to type “beach” and get the
beach photos is what turns a self-hosted archive into something people open.</p>

<h2 id="running-it-on-a-ds920">Running it on a DS920+</h2>

<p>Three details in my compose are Synology-specific and worth stealing.</p>

<p><strong>Hardware acceleration reuses the same GPU passthrough as everything else.</strong>
The DS920+’s Intel chip does the transcoding and the machine learning, using
the same <code class="highlighter-rouge">/dev/dri</code> device access that got
<a href="/2026/08/11/plex-docker-intel-quick-sync/">Plex hardware transcoding working</a>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">extends</span><span class="pi">:</span>
  <span class="na">file</span><span class="pi">:</span> <span class="s">hwaccel.transcoding.yml</span>
  <span class="na">service</span><span class="pi">:</span> <span class="s">vaapi</span>
</code></pre></div></div>

<p>and for the ML container, OpenVINO rather than CPU:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-openvino</span>
<span class="na">extends</span><span class="pi">:</span>
  <span class="na">file</span><span class="pi">:</span> <span class="s">hwaccel.ml.yml</span>
  <span class="na">service</span><span class="pi">:</span> <span class="s">openvino</span>
</code></pre></div></div>

<p>The first library scan does face detection and search indexing over every
photo you own. On CPU that is a weekend. On the iGPU it is an evening.</p>

<p><strong>Tell Postgres it is on spinning disks.</strong> The database sits on the same
mechanical drives as everything else, not an SSD cache, and Immich will tune
itself accordingly if you say so:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">DB_STORAGE_TYPE</span><span class="pi">:</span> <span class="s1">'</span><span class="s">HDD'</span>
</code></pre></div></div>

<p><strong>Ignore Synology’s own clutter.</strong> If you point Immich at a share that
Synology Photos has ever touched, it will happily import thousands of
generated preview and proxy files as though they were real photos. One line
in <code class="highlighter-rouge">.env</code> stops that:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IGNORE_FILES_PATTERN=SYNOPHOTO_*
</code></pre></div></div>

<h2 id="the-one-stack-watchtower-is-not-allowed-near">The one stack Watchtower is not allowed near</h2>

<p>Everything else on the NAS updates itself. <a href="https://github.com/containrrr/watchtower">Watchtower</a>
runs at 1am, pulls new images, restarts containers, cleans up the old
layers, and I mostly find out because something looks slightly different.</p>

<p>Immich is the exception, and every container in the stack says so:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">labels</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">com.centurylinklabs.watchtower.enable=false"</span>
</code></pre></div></div>

<p>Immich moves quickly and releases regularly carry database migrations. An
unattended 1am pull that half-migrates a Postgres database holding the
family photo library is not a risk worth taking to save five minutes of
attention a month. Upgrades here are deliberate: read the release notes,
pull, run it, check it.</p>

<p>The same reasoning applies to the two supporting images, which are pinned to
digests rather than floating tags:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">image</span><span class="pi">:</span> <span class="s">docker.io/valkey/valkey:8-bookworm@sha256:fea8b3e6...</span>
<span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357...</span>
</code></pre></div></div>

<p>Pinning the database image is not paranoia. The Postgres container carries
the vector extensions Immich’s search depends on, and a surprise major
version bump underneath a running library is exactly the kind of morning
nobody wants.</p>

<h2 id="the-alongside-part-external-libraries">The “alongside” part: external libraries</h2>

<p>The thing that made Immich viable rather than just interesting is external
libraries. Immich can index folders it does not own, in place, without
moving or restructuring anything:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">${UPLOAD_LOCATION}:/data</span>
  <span class="pi">-</span> <span class="s">/volume1/homes/andrew/Photos:/Archive_Import</span>
  <span class="pi">-</span> <span class="s">/volumeUSB2/usbshare2-2:/USB_Archive:ro</span>
</code></pre></div></div>

<p><code class="highlighter-rouge">/data</code> is the library Immich manages, at <code class="highlighter-rouge">/volume1/photo</code>. The other two
are decades of accumulated photos that already exist elsewhere: an archive
folder in my home directory, and an external USB drive.</p>

<p>Note the <code class="highlighter-rouge">:ro</code> on the USB share. Immich has no business writing to a
historical archive, so it cannot. If I ever misconfigure a retention rule or
fat-finger a bulk delete, the twenty-year-old scans are behind a read-only
mount and simply are not reachable. That mount flag is the cheapest
insurance in the whole setup.</p>

<h2 id="the-instead-part-honestly">The “instead” part, honestly</h2>

<p>I am not going to claim iCloud Photos is switched off, because it is not.</p>

<p>What has changed is which one I would be upset to lose. Immich now holds the
complete archive, including everything that predates iCloud and everything
that was scattered across old drives, and it takes the background upload
from the phones. iCloud has quietly become the working set: the last couple
of years, synced across devices, feeding the Apple TV screensaver and shared
albums with people who are never going to install a self-hosted photo app.</p>

<p>The things still keeping iCloud in the picture are the integration bits
rather than the storage. Shared albums with family who are not on Immich.
Live Photos behaving properly everywhere. The Photos app being the thing
that opens when you tap a photo in Messages. None of those are Immich
failures, they are the cost of an ecosystem that is genuinely well built.</p>

<p>The direction is clear enough though. Every month the storage tier matters
less, because the thing I would actually grieve losing is already sitting on
a drive in the study.</p>

<h2 id="one-caveat-worth-stating-loudly">One caveat worth stating loudly</h2>

<p>Immich on the NAS is not a backup. RAID is not a backup either. Both protect
against a drive failing, and neither protects against the house being
burgled, flooded, or the array being corrupted by something I did to it at
11pm.</p>

<p>Right now the honest position is that iCloud is doing double duty as the
off-site copy for recent photos, and the older archive is not as protected
as it should be. That is the next problem to solve, and probably the next
post.</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="immich" /><summary type="html"><![CDATA[This is an all-Apple house, so iCloud Photos is the path of least resistance and it works well. That is precisely the problem. It is the one part of the setup where the only copy of something irreplaceable lives somewhere I do not control, on a subscription that gets renewed forever because the alternative is deciding which photos to delete.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/immich-alongside-and-instead-of-icloud.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/immich-alongside-and-instead-of-icloud.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Getting My Electric Car into HomeKit, and Proving One Feature Was Impossible</title><link href="https://andrew-snape.github.io/2026/08/14/mg4-into-homekit/" rel="alternate" type="text/html" title="Getting My Electric Car into HomeKit, and Proving One Feature Was Impossible" /><published>2026-08-14T00:00:00+10:00</published><updated>2026-08-14T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/14/mg4-into-homekit</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/14/mg4-into-homekit/"><![CDATA[<p>I drive an MG4, and like most modern EVs it comes with a phone app – “iSmart” – for checking battery level, locking the doors, and firing off the odd remote command before you’ve even walked out to the car. It works fine. It is also its own separate app, with its own separate account, sitting completely outside the Home app where every other device in the house lives. So <a href="https://github.com/andrew-snape/homebridge-mg-saic"><code class="highlighter-rouge">homebridge-mg-saic</code></a> exists to fix that: a Homebridge plugin that talks to the same undocumented cloud API the iSmart app uses, and puts the car in HomeKit alongside everything else.</p>

<p>This one’s a single-vehicle plugin on purpose – I have one MG4, and building for a fleet nobody’s testing against felt like the wrong kind of ambitious. It’s real, published, MIT-licensed software, and it runs continuously against my actual car, not a demo account.</p>

<h2 id="reverse-engineering-saics-cloud-api">Reverse-engineering SAIC’s cloud API</h2>

<p>There’s prior art here – <a href="https://github.com/SAIC-iSmart-API/saic-python-client-ng"><code class="highlighter-rouge">SAIC-iSmart-API/saic-python-client-ng</code></a>, an existing MIT-licensed Python client – and the plugin ports the request <em>shapes</em> from that project rather than the code itself, since this is plain Node, not Python. A few things about the API were worth knowing before ever pointing it at a real car:</p>

<ul>
  <li>Every request body is AES-128-CBC encrypted, key and IV both derived from MD5 hashes of the request path, a tenant ID, the auth token, and the timestamp, plus a separate <code class="highlighter-rouge">APP-VERIFICATION-STRING</code> header that’s an HMAC-SHA256 over similar material. None of it documented anywhere official.</li>
  <li>The VIN has to be sent as a SHA-256 hash, never in the clear. Send the raw VIN and you get error <code class="highlighter-rouge">36805</code>, “not within the package scope” – which reads exactly like an account or subscription problem and is actually just “you hashed this wrong.”</li>
  <li>Status and command endpoints are all asynchronous the same way: the first call returns an <code class="highlighter-rouge">event-id</code> with no data. You resend the identical request with that <code class="highlighter-rouge">event-id</code> attached, repeatedly, until a response finally carries data – anywhere from a couple of seconds to most of a minute if the car’s asleep and has to wake up over its own cellular connection.</li>
  <li>SAIC only allows one active session per account, full stop. Logging in from the plugin silently signs the phone app out. Not a bug, just how the backend works – worth a line in the README so nobody thinks they’ve broken something.</li>
  <li>The gateway appears to rate-limit an account for roughly 15 minutes if it sees repeated logins in a short window from a new client, which is why the plugin polls conservatively, every 15 minutes by default.</li>
  <li>Region matters – there’s a separate gateway hostname per market (<code class="highlighter-rouge">gateway-mg-au.soimt.com</code> for Australia, and equivalents for EU, China, Brazil, Israel, Turkey, India, Thailand), configurable in the plugin.</li>
</ul>

<p>Every write command – lock, unlock, heated seats, rear defrost, windows – goes through one endpoint, <code class="highlighter-rouge">POST /vehicle/control</code>, with a body like:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"rvcReqType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;command type, as a string number&gt;"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"rvcParams"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"paramId"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w"> </span><span class="nl">"paramValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;base64 of raw bytes&gt;"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"vin"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;sha256 hex of VIN&gt;"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="highlighter-rouge">rvcReqType</code> selects the command family, <code class="highlighter-rouge">rvcParams</code> is a flat array of typed, byte-packed parameters specific to it, and every observed request ends with a <code class="highlighter-rouge">paramId: 255</code> zero-byte sentinel whose actual purpose is unclear – it’s just there, in every example, so the plugin sends it too. It reads like a protocol designed for something CAN-bus-adjacent that got wrapped in JSON and HTTPS as an afterthought for the cloud, which – once you’ve stared at enough of these vehicle APIs – it more or less always is.</p>

<p>Two commands the reference client supports and this plugin deliberately doesn’t: engine control and the remote immobilizer. Interesting to know exist, not something a hobby project needs anywhere near a real car.</p>

<h2 id="building-it-without-risking-the-real-car-or-the-real-house">Building it without risking the real car or the real house</h2>

<p>The build order mattered as much as the code. A standalone test rig went first – a raw Node script, then a throwaway Homebridge instance paired to a separate test Home – specifically so a bad login attempt couldn’t sign the real iSmart app out or corrupt the Home everyone in the house actually uses. Only after that went onto the real Homebridge instance, and even then, read-only first: battery, lock state, door/boot/bonnet contact sensors, charging status. Writable lock/unlock came later, and only after its request was checked byte-for-byte against the reference client using a mocked send function that logs the outgoing JSON instead of transmitting it – so the first real command sent to the car was one I’d already confirmed matched a known-working implementation exactly.</p>

<p><strong>It worked.</strong> Unlocking the plugin’s HomeKit switch actually opens the doors.</p>

<p>From there: interior/exterior temperature sensors (guarded against the API’s occasional <code class="highlighter-rouge">-128</code> “field unavailable” sentinel, which now falls back to the last good reading and raises a HomeKit fault instead of showing a nonsense number), heated seats labelled by physical side rather than driver/passenger (the API’s own labelling is ambiguous across left- and right-hand-drive markets), rear window defrost, and window open/close. The last three shipped <strong>off by default</strong>, gated behind their own config flags, because they were new and nothing had confirmed them against the real car yet.</p>

<p>Heated seats and rear defrost: tested, confirmed working. Windows: tested, and consistently failed. That’s the interesting part of this post.</p>

<h2 id="the-window-mystery">The window mystery</h2>

<p>“Pre-heaters work, but windows don’t open or close” was the report. Reasonable enough on its face – except the Homebridge log told a different story once I actually read it.</p>

<p>The log showed <code class="highlighter-rouge">Timed out after 60s waiting for the vehicle</code> – but across <em>every</em> command type, not just windows. Locks, seat heat, rear defrost, all timing out, which flatly contradicted “seats and defrost work.” That contradiction was the real clue, and it would have been easy to tunnel straight to “windows are broken” and miss it. The timestamps explained it: six separate seat-heat attempts inside a twenty-second window, interleaved with window, defrost, and lock attempts – someone (me) tapping switches enthusiastically in the Home app rather than one command at a time.</p>

<p><strong>Bug one, real, and fixed:</strong> the API client had no concept of “one command at a time.” Every call ran its own independent poll loop against the same car, and the backend apparently only tracks one in-flight command per vehicle – so overlapping calls just timed each other out, even if the vehicle had already reacted to the first one. Fixed with a small promise-chain queue in front of every <code class="highlighter-rouge">/vehicle/control</code> call, so a second tap now waits its turn instead of racing the first. Verified with a dry-run script that fired three concurrent fake commands and asserted no two were ever in flight at once – pass – before it went anywhere near the real car again.</p>

<p>That’s a genuinely general lesson: if the device can only execute one command at a time, your client has to enforce that itself. The device’s own API will not save you, and “tapped the button twice” isn’t a hypothetical – it happened in the very first real testing session.</p>

<p>Except the mystery wasn’t actually solved. A single, isolated window command – nothing else running – still failed after the full sixty seconds. This time, better debug logging (added on the fly, mid-investigation, straight onto the live plugin files on the NAS over the network, ahead of a proper release, because the diagnostic detail was needed for the <em>next</em> test, not next week) showed exactly what the car kept saying, every three seconds, for the whole minute:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code=4 event-id=- data=no message=The remote control instruction failed, please try again later.
</code></pre></div></div>

<p>Just “still not done,” forever, with no real progress – which ruled out overlapping commands (there weren’t any this time) and pointed at either the car or the backend flatly declining to make progress on this one command. A follow-up test surfaced a sharper error:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code=8 event-id=- data=no message=Request failed. Please check the vehicle status and try again.(255)
</code></pre></div></div>

<p>alongside, almost simultaneously:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code=8 event-id=- data=no message=Other remote command in progress. Please try again later.(3)
</code></pre></div></div>

<p>That second message was actually useful confirmation of the <em>first</em> bug – the backend genuinely does reject overlapping commands with an explicit error when it sees them – but it also meant this “clean” test wasn’t clean: the live NAS install had only received the logging patch, not the queue fix itself, which was still sitting in git. So the concurrency fix had to go live too before the next test could be trusted.</p>

<p>With that done, and a lead from outside research – a public project building a local Android-Automotive-OS app for MG4s (<a href="https://github.com/dragonro/MG4_winclose"><code class="highlighter-rouge">dragonro/MG4_winclose</code></a>) documented, for a completely different interface into the same car, that window motors lose power the moment the car locks, and any close command sent after that point simply does nothing – I ran a properly controlled final test: unlock, immediately open the driver’s door and hold it, fire the window command while the door’s still open and the car’s accessory power should be live. A few iterations, including with the car freshly started.</p>

<p><strong>Still failed. Every time, the same way.</strong> Which is a real answer, just not the one I wanted: this is a genuine limitation of this vehicle, on this software version, not a bug in the plugin.</p>

<h2 id="removing-what-doesnt-work">Removing what doesn’t work</h2>

<p>Rather than leave a switch in the plugin that either silently does nothing or occasionally times out and shows “Not Responding,” version <code class="highlighter-rouge">0.6.0</code> removed window control outright: the four <code class="highlighter-rouge">Switch</code> services and the <code class="highlighter-rouge">enableWindowControls</code> config option are gone from the accessory. The low-level <code class="highlighter-rouge">controlWindow()</code> method and its constants stay in the API client, unused, clearly commented as tried-and-confirmed-not-working-on-this-hardware, for anyone with different hardware or a future firmware update who wants to pick it up. The README, API docs, and changelog all carry the specific finding: tested against a real MG4 on software version <strong>SWi165 - R11 (Australia)</strong>, tried locked, unlocked-with-door-open, and freshly-started, always the same <code class="highlighter-rouge">code 8</code> rejection. Heated seats and rear defrost, shipped as “unverified” in the same release as windows, got promoted to “confirmed working” once the real car had proven them out.</p>

<p>Documenting a negative result properly – the exact error codes, the exact conditions tried, the exact conclusion – felt like it mattered more here than either quietly shipping a feature that doesn’t work or quietly deleting all evidence it was ever attempted. Someone else with an MG4, or a different firmware version, gets a real starting point instead of having to rediscover all of this from scratch.</p>

<h2 id="two-smaller-things-worth-knowing">Two smaller things worth knowing</h2>

<p><strong>HomeKit caches accessory and service names at first pairing, permanently, client-side.</strong> Rename a service in the plugin’s code afterwards and the Home app just keeps showing the old name – the only fixes are renaming it manually inside Home, or removing and re-adding the accessory. Tripped me up when a code change didn’t visibly do anything, and it’s a genuinely useful thing to know before you build any HomeKit accessory of your own.</p>

<p><strong>Live-patching a running system mid-investigation is sometimes the right call, not a shortcut you feel bad about.</strong> My Homebridge config directory is reachable over the network from my Mac, so the installed plugin files could be edited directly and the affected child bridge restarted from the Config UI, without a full release cycle, purely to get the debug output the next test actually needed. The fix goes through the normal commit-and-release path once you know what you’re doing – but getting unblocked <em>right now</em> beat waiting on a pipeline built for a different situation.</p>

<h2 id="try-it">Try it</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> homebridge-mg-saic
</code></pre></div></div>

<p>Repo’s at <a href="https://github.com/andrew-snape/homebridge-mg-saic">andrew-snape/homebridge-mg-saic</a>, MIT licensed, full credit to <a href="https://github.com/SAIC-iSmart-API/saic-python-client-ng"><code class="highlighter-rouge">SAIC-iSmart-API/saic-python-client-ng</code></a> for mapping the API first. If you’ve got an MG4 on different hardware or firmware and want to take another run at the window mystery, the escape hatch is still sitting there in the code, commented and waiting.</p>

<p>Andrew</p>

<h2 id="update-pre-conditioning-confirmed-and-another-not-a-bug">Update: pre-conditioning confirmed, and another “not a bug”</h2>

<p>Six releases since this post went up (0.7.0 through 0.9.4). The short version: it got faster, it got more resilient, and the one feature still marked “implemented but nobody’s actually tried it on the real car” finally got tried.</p>

<p><strong>Speed and reliability first.</strong> Vehicle status and charging status now fetch in parallel instead of one after the other, which roughly halves refresh time when the car’s asleep and has to wake up over its own cellular connection. An expired token now triggers an automatic re-login mid-poll instead of requiring a Homebridge restart. The whole codebase moved to TypeScript, with a Vitest suite and ESLint running in CI ahead of every publish.</p>

<p><strong>Pre-conditioning — the remote aircon — is now confirmed working.</strong> Tapping it in Home returned <code class="highlighter-rouge">code: 0</code> after about 16 seconds and three polls, and the climate system audibly kicked on.</p>

<p>It came with its own small mystery, same shape as the window one: it runs the <em>heater</em>, every time, regardless of ambient temperature — and that’s correct, not a bug. The command asks for a fixed 22°C and never sets the compressor flag, and on this car heating runs on a resistive element with the compressor off. So “pre-conditioning” means “warm the cabin to 22°C,” not “set my preferred temperature” — worth knowing before you rely on it on a 40-degree day. Now documented properly in <code class="highlighter-rouge">docs/API.md</code>, including the temperature-index formula and a specific warning that fan-speed bytes 4 and 5 aren’t higher fan speeds — they silently put the car into heat-plus-front-defrost.</p>

<p>Two real bugs turned up from actual usage logs, not lab testing. A command the car explicitly rejects (<code class="highlighter-rouge">code: 8</code>) used to retry for the full 60 seconds before failing, burying a message like “Vehicle is powered on. Please turn it off and try again.” — it now fails immediately and surfaces that message. Same log run showed the car requires being <strong>locked with the ignition off</strong> before it’ll accept seat-heat or rear-defrost commands remotely — a vehicle-side restriction, not something the plugin can work around, but useful to know before assuming a command’s gone nowhere. And cached-accessory edits weren’t being written back to Homebridge’s on-disk cache, so a newly enabled switch could stay invisible in Home until you removed and re-paired the accessory.</p>

<p>Full blow-by-blow is in <a href="https://github.com/andrew-snape/homebridge-mg-saic/blob/main/CHANGELOG.md">CHANGELOG.md</a>.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="projects" /><category term="homelab" /><category term="homebridge" /><summary type="html"><![CDATA[I drive an MG4, and like most modern EVs it comes with a phone app – “iSmart” – for checking battery level, locking the doors, and firing off the odd remote command before you’ve even walked out to the car. It works fine. It is also its own separate app, with its own separate account, sitting completely outside the Home app where every other device in the house lives. So homebridge-mg-saic exists to fix that: a Homebridge plugin that talks to the same undocumented cloud API the iSmart app uses, and puts the car in HomeKit alongside everything else.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/mg4-into-homekit.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/mg4-into-homekit.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The One Docker Mount That Makes the *arrs Actually Work</title><link href="https://andrew-snape.github.io/2026/08/14/one-docker-mount-for-the-arrs/" rel="alternate" type="text/html" title="The One Docker Mount That Makes the *arrs Actually Work" /><published>2026-08-14T00:00:00+10:00</published><updated>2026-08-14T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/14/one-docker-mount-for-the-arrs</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/14/one-docker-mount-for-the-arrs/"><![CDATA[<p>If you run Sonarr, Radarr or Lidarr in Docker and imports feel slow, or your
free space drops by the size of every episode twice, the cause is almost
always the same thing: too many volume mounts. The fix is boring and it is
one line per container.</p>

<h2 id="the-symptom">The symptom</h2>

<p>A download finishes. The *arr picks it up and imports it. On a healthy setup
that import is instant, no matter how big the file, and the download stays
seeding without using a second copy of the disk space. On a broken setup the
import takes as long as it would take to copy the file, and for a while you
are holding two full copies of every release.</p>

<p>That difference is a hardlink versus a copy, and whether you get one or the
other comes down to how the container sees the filesystem.</p>

<h2 id="why-it-happens">Why it happens</h2>

<p>A hardlink is a second name for the same data on disk. It costs nothing and
takes no time, but it only works within a single filesystem. The same is
true of an atomic move: renaming a file within one filesystem is instant,
while “moving” across filesystems is really copy-then-delete.</p>

<p>Here is the trap. On the NAS, <code class="highlighter-rouge">/volume1/downloads</code> and <code class="highlighter-rouge">/volume1/TVShows</code>
are the same filesystem. But if you hand the container two separate bind
mounts:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">/volume1/downloads:/downloads</span>
  <span class="pi">-</span> <span class="s">/volume1/TVShows:/tv</span>
</code></pre></div></div>

<p>then inside the container those are two separate mount points. The import
gets treated as a cross-device operation, the hardlink is refused, and the
move degrades to a full byte-for-byte copy followed by a delete. The host
knew they were the same filesystem. The container did not.</p>

<h2 id="the-fix">The fix</h2>

<p>Mount the parent once, and let the paths sit underneath it:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">/volume1/config/sonarr:/config</span>
  <span class="pi">-</span> <span class="s">/volume1:/data</span>
</code></pre></div></div>

<p>That is it. Every one of my *arrs and both download clients carry the same
<code class="highlighter-rouge">/volume1:/data</code> line, with a comment in the compose file so future me does
not “tidy it up”:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="s">/volume1:/data</span>          <span class="c1"># single mount: downloads + libraries on one filesystem</span>
</code></pre></div></div>

<p>Inside the container the root folder becomes <code class="highlighter-rouge">/data/TVShows</code>, and the
download client’s completed folder becomes <code class="highlighter-rouge">/data/downloads/complete</code>. Both
sit under one mount point, so hardlinks and atomic moves work the way they
were meant to.</p>

<p>The download client has to agree. If SABnzbd reports a path the *arr cannot
resolve identically, you end up reaching for Remote Path Mappings, which is
a patch over a problem you no longer need to have. Give SABnzbd and
Transmission the same <code class="highlighter-rouge">/volume1:/data</code> and the paths line up on their own.</p>

<h2 id="the-other-half-permissions">The other half: permissions</h2>

<p>Hardlinks also need the process to be allowed to create them. Every
container in the stack runs as the same user and group, with the same umask:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">environment</span><span class="pi">:</span>
  <span class="na">PUID</span><span class="pi">:</span> <span class="m">1026</span>
  <span class="na">PGID</span><span class="pi">:</span> <span class="m">101</span>
  <span class="na">UMASK</span><span class="pi">:</span> <span class="s2">"</span><span class="s">002"</span>
</code></pre></div></div>

<p><code class="highlighter-rouge">UMASK: "002"</code> is the part people miss. It makes new files group-writable,
so the *arr that imports a file and the client that downloaded it are not
fighting each other over who owns what.</p>

<h2 id="the-exceptions-and-why-they-exist">The exceptions, and why they exist</h2>

<p>Not everything wants the single mount, and it is worth being deliberate
about which.</p>

<p>Plex only ever reads. It never imports, never moves, never hardlinks, so it
gets narrow, purpose-named mounts instead: <code class="highlighter-rouge">/volume1/Movies:/movies</code>,
<code class="highlighter-rouge">/volume1/TVShows:/tv</code>, and so on. Narrower is better when a container has
no business writing to the library.</p>

<p>Prowlarr gets no media mounts at all. It only syncs indexers to the other
*arrs, so there is nothing for it to see.</p>

<p>And then there is the honest wart. Lidarr carries one extra mount purely so
that <a href="https://github.com/mrusse/soularr">Soularr</a> keeps working:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="s">/volume1/downloads/complete/slskd:/downloads/slskd</span>   <span class="c1"># kept so Soularr keeps working</span>
</code></pre></div></div>

<p>That is exactly the pattern this post is arguing against, and it is there
because a third-party tool hardcoded expectations about the path. The right
answer was to point Soularr at the same folder expressed through the <code class="highlighter-rouge">/data</code>
mount instead, so the import stays a hardlink, which is what it does now.
The extra mount is a leftover I have not removed yet. More on that stack in
the next post.</p>

<h2 id="how-to-check-yours">How to check yours</h2>

<p>The quickest test is to look at the link count. After an import, run this on
the host against the downloaded file:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-li</span> /volume1/downloads/complete/some.release.mkv
</code></pre></div></div>

<p>The number in the second column is the link count. If it is <code class="highlighter-rouge">2</code>, you have a
hardlink and the library copy is the same data on disk. If it is <code class="highlighter-rouge">1</code>, you
have two separate copies and you are paying for both.</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="docker" /><summary type="html"><![CDATA[If you run Sonarr, Radarr or Lidarr in Docker and imports feel slow, or your free space drops by the size of every episode twice, the cause is almost always the same thing: too many volume mounts. The fix is boring and it is one line per container.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/one-docker-mount-for-the-arrs.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/one-docker-mount-for-the-arrs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Getting a Samsung Soundbar into HomeKit: A Homebridge Plugin, Shipped Twice</title><link href="https://andrew-snape.github.io/2026/08/14/samsung-soundbar-homekit-plugin/" rel="alternate" type="text/html" title="Getting a Samsung Soundbar into HomeKit: A Homebridge Plugin, Shipped Twice" /><published>2026-08-14T00:00:00+10:00</published><updated>2026-08-14T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/14/samsung-soundbar-homekit-plugin</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/14/samsung-soundbar-homekit-plugin/"><![CDATA[<p>The soundbar in the lounge does AirPlay 2 fine, but it has never once shown up in the Home app’s “Add Accessory” flow – Samsung just never built HomeKit support in, and the official path to controlling it from a phone is the SmartThings app, with its own account and its own cloud round trip for a command as simple as “turn down the volume.” I didn’t want a second smart-home ecosystem for one device, so this turned into <a href="https://github.com/andrew-snape/homebridge-samsung-soundbar-local"><code class="highlighter-rouge">@snapeos/homebridge-samsung-soundbar-local</code></a> – a Homebridge plugin for local, no-cloud control of D-series-and-later Samsung soundbars, verified against my own HW-Q930D.</p>

<h2 id="finding-the-actual-interface">Finding the actual interface</h2>

<p>The soundbar still runs something – SmartThings has to talk to it somehow – and it turns out these devices ship an on-board “IP Control” server that Samsung uses internally. The protocol itself was reverse-engineered first by <a href="https://github.com/ZtF/hass-samsung-soundbar-local">ZtF/hass-samsung-soundbar-local</a> for Home Assistant; this plugin ports that knowledge over to Homebridge/HomeKit, credited properly in the README.</p>

<p>It’s JSON-RPC 2.0 over TLS on port 1516, behind a self-signed certificate issued to “Samsung IP Control G2” (shared across Samsung display products generally, not per-device – so TLS verification has to be disabled outright, not just relaxed). The handshake mints a token you attach to every call after:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sk</span> https://192.168.0.45:1516/ <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Accept: application/json'</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"jsonrpc":"2.0","method":"createAccessToken","id":1}'</span>
</code></pre></div></div>

<p>That <code class="highlighter-rouge">Accept: application/json</code> header isn’t optional, and the way it fails if you skip it is the kind of thing that costs you an hour: drop it and the server comes back <code class="highlighter-rouge">400 Bad Request</code> with a <code class="highlighter-rouge">text/xml</code> content type, which reads like “you’re speaking the wrong protocol entirely,” not “you forgot a header.” Both traps are called out explicitly in the README and in the code, because nothing about either failure mode points at its actual cause.</p>

<h2 id="volume-without-a-volume-knob">Volume without a volume knob</h2>

<p>The RPC surface is small and mostly does what you’d expect – <code class="highlighter-rouge">powerControl</code>, <code class="highlighter-rouge">getVolume</code>, <code class="highlighter-rouge">getMute</code>, <code class="highlighter-rouge">inputSelectControl</code>, <code class="highlighter-rouge">soundModeControl</code>, <code class="highlighter-rouge">getCodec</code>. One genuine quirk: several of these are getter and setter on the same endpoint, disambiguated only by whether you passed a value – call <code class="highlighter-rouge">powerControl</code> with no <code class="highlighter-rouge">power</code> param and it just tells you the current state instead of changing anything.</p>

<p>The real oddity is volume. There’s no “set volume to 37” method anywhere in the protocol – only relative stepping, <code class="highlighter-rouge">VOL_UP</code> and <code class="highlighter-rouge">VOL_DOWN</code> via <code class="highlighter-rouge">remoteKeyControl</code>. HomeKit, meanwhile, wants an absolute 0-100 slider. So <code class="highlighter-rouge">setVolume</code> reads the current level and fires repeated up/down presses until it reaches the target, capped at 25 steps per call so a slider drag can’t machine-gun the device with requests.</p>

<p>There’s a subtler trap underneath that: <code class="highlighter-rouge">getVolume</code> lags roughly one press behind the device’s actual state at the cadence this plugin uses, so re-polling <em>between</em> steps to check progress causes overshoot – the loop thinks it hasn’t arrived yet when it has, and keeps stepping past the target. The fix is to not ask: the stepping loop tracks the target locally and trusts its own count, hands that value back to HomeKit immediately, and only re-confirms against the real device with a debounced “settle” poll about 900ms after the last press.</p>

<p>Token handling has its own small discipline: tokens are cached, concurrent requests are coalesced so the plugin never mints two at once under parallel calls, and any failed RPC call invalidates the cached token and retries once – because a stale token after the soundbar reboots just looks like a generic failure, not a clean “unauthorized” you could branch on.</p>

<h2 id="one-soundbar-one-pairing">One soundbar, one pairing</h2>

<p>HomeKit only allows a single <code class="highlighter-rouge">Television</code> service per bridge, so the accessory has to be published as an <em>external</em> accessory rather than through Homebridge’s normal cached-bridge flow. The practical effect: after restarting Homebridge, you don’t just see it appear – you have to go into the Home app, Add Accessory, “More options,” and pair it with the same PIN as your main Homebridge bridge, as if it were a second, separate bridge. It’s a one-time gotcha but a genuinely confusing one if you don’t know it’s coming, and it’ll bite anyone building a HomeKit TV, soundbar, or receiver integration, not just this one.</p>

<h2 id="getting-it-onto-npm">Getting it onto npm</h2>

<p>Before shipping, a general health pass on the repo turned up two real bugs that had nothing to do with the protocol work: no <code class="highlighter-rouge">.gitignore</code> at all (<code class="highlighter-rouge">node_modules/</code> and <code class="highlighter-rouge">dist/</code> one <code class="highlighter-rouge">git add -A</code> away from landing in a commit), and <code class="highlighter-rouge">PLUGIN_NAME</code> hardcoded unscoped in <code class="highlighter-rouge">src/settings.ts</code> while the actual npm package name is scoped, <code class="highlighter-rouge">@snapeos/homebridge-samsung-soundbar-local</code>. Both fixed, along with a <code class="highlighter-rouge">package.json</code> metadata mismatch in the same family – <code class="highlighter-rouge">repository</code>, <code class="highlighter-rouge">bugs</code>, and <code class="highlighter-rouge">homepage</code> all pointed at <code class="highlighter-rouge">github.com/snapeos/...</code> instead of <code class="highlighter-rouge">github.com/andrew-snape/...</code>, because the npm scope and the GitHub username aren’t the same string and it’s easy to type one where you meant the other.</p>

<p>Release itself went out as a GitHub Actions workflow triggered on <code class="highlighter-rouge">v*.*.*</code> tags: <code class="highlighter-rouge">npm ci</code>, <code class="highlighter-rouge">npm run build</code>, <code class="highlighter-rouge">npm publish</code>, reading an <code class="highlighter-rouge">NPM_TOKEN</code> repo secret. Simple in principle. Cutting the actual first release found six separate ways for that to go sideways, each one small and specific enough to be worth listing:</p>

<ol>
  <li><strong>The session’s own git access turned out to be PR-only.</strong> Pushing a tag directly from inside Claude Code got a flat <code class="highlighter-rouge">403</code>, and the GitHub tooling available had no tag-or-release-creation call at all – only branches and PRs. Direct pushes to <code class="highlighter-rouge">main</code> or tag refs just aren’t permitted from that kind of session, by design. Had to hand the tag push back to my own machine.</li>
  <li><strong>zsh doesn’t treat inline <code class="highlighter-rouge">#</code> as a comment by default</strong>, unlike a bash script. A copy-pasted command with a trailing <code class="highlighter-rouge"># explanation</code> comment got the entire comment text, em dash included, passed straight to <code class="highlighter-rouge">npm</code> as a literal argument, and it choked on the dash.</li>
  <li><strong><code class="highlighter-rouge">git push</code> over HTTPS prompted for a username and password.</strong> GitHub dropped plain password auth for git years ago; it wants a personal access token in that field, and none was cached.</li>
  <li><strong><code class="highlighter-rouge">npm ci</code> failed outright</strong> – it hard-requires a committed lockfile, and <code class="highlighter-rouge">package-lock.json</code> had only ever existed locally, never actually <code class="highlighter-rouge">git add</code>ed.</li>
  <li><strong>A tag “move” silently didn’t move.</strong> Deleting and recreating the tag <em>looked</em> like it worked – no errors – but it still pointed at the old broken commit, because the local <code class="highlighter-rouge">main</code> it was cut from hadn’t actually fast-forwarded first. Only caught on the next attempt by inserting an explicit <code class="highlighter-rouge">git log -1 --oneline</code> checkpoint before retagging and reading the output back before proceeding.</li>
  <li><strong>npm rejected the publish for 2FA reasons</strong> – <code class="highlighter-rouge">403</code>, “granular access token with bypass 2fa enabled is required.” The token behind <code class="highlighter-rouge">NPM_TOKEN</code> was a type that expects an interactive one-time code, which obviously can’t happen inside CI. Fixed on npmjs.com’s end: regenerate as a classic Automation token, or a granular one with the bypass-2FA permission ticked, then update the secret.</li>
</ol>

<p>The best part of that release day, though, wasn’t any of the above – it’s what happened <em>while</em> #4 was still broken. The failed run auto-triggered GitHub’s own Copilot “autofix” bot, a completely separate AI agent, native to Actions. It cloned the repo on its own branch, diagnosed the exact same missing-lockfile problem, ran <code class="highlighter-rouge">npm install --package-lock-only</code>, and opened its own PR. And its logs showed it was running on <code class="highlighter-rouge">claude-sonnet-4.6</code> under the hood – so for a few minutes, two different AI coding agents were independently working the same repo, on related but uncoordinated problems, one of them Claude Code and the other GitHub’s own bot also running on a Claude model. I merged its PR in about a minute, same as everything else that week, and it turned out to be the fix that got the lockfile issue closed out for good.</p>

<p>Once the token was sorted, the workflow went green and I checked the registry directly rather than just trusting CI:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> https://registry.npmjs.org/@snapeos/homebridge-samsung-soundbar-local
<span class="c"># {"name":"@snapeos/homebridge-samsung-soundbar-local","versions":["1.0.0"], ...}</span>
</code></pre></div></div>

<p><code class="highlighter-rouge">1.0.0</code> was live.</p>

<h2 id="and-then-it-broke-again">And then it broke again</h2>

<p>A few days later, in a fresh session, I corrected a real factual error: the README, the config schema, and the code all defaulted <code class="highlighter-rouge">maxVolume</code> to <code class="highlighter-rouge">40</code>, described as roughly where the HW-Q930D tops out. It doesn’t – it goes to 100. That’s not a logic bug; the scaling option itself worked fine for whatever number you gave it. It’s a domain fact about a physical device that no amount of reading the code or unit testing could have caught – only owning the hardware tells you that. Fixed the default in three places (schema, <code class="highlighter-rouge">platformAccessory.ts</code>, README) and committed it.</p>

<p>Tagging <code class="highlighter-rouge">v1.0.1</code> and pushing triggered the same publish workflow, which failed at the very last step:</p>

<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm error code E403
npm error 403 Forbidden - PUT https://registry.npmjs.org/@snapeos%2fhomebridge-samsung-soundbar-local
npm error 403 You cannot publish over the previously published versions: 1.0.0.
</code></pre></div></div>

<p><code class="highlighter-rouge">npm publish</code> reads the version to ship from <code class="highlighter-rouge">package.json</code>, not from the git tag – and <code class="highlighter-rouge">package.json</code> still said <code class="highlighter-rouge">1.0.0</code>. Tagging a release and bumping the package version are two separate manual steps in this repo, and I’d simply skipped the second one. npm was correctly refusing to let me republish a version that was already live; that’s the registry working as designed, not a fluke.</p>

<p>The annoying part was that <code class="highlighter-rouge">v1.0.1</code> was now a burned tag – already pushed, pointing at a commit with the wrong version baked in. Retagging it onto the fix would mean force-pushing over an already-public tag, which is the kind of history-rewrite that’s generally worth avoiding, more so for something as visible as a release tag. So instead of forcing it, I bumped forward: <code class="highlighter-rouge">package.json</code> and <code class="highlighter-rouge">package-lock.json</code> to <code class="highlighter-rouge">1.0.2</code>, committed, tagged <code class="highlighter-rouge">v1.0.2</code> fresh, pushed. No force-push, nothing rewritten. <code class="highlighter-rouge">v1.0.1</code> just sits there in the tag list now, permanently unused – a small, harmless scar that documents the mistake better than deleting it ever would. The workflow ran again and published cleanly; <code class="highlighter-rouge">npm view @snapeos/homebridge-samsung-soundbar-local version</code> came back <code class="highlighter-rouge">1.0.2</code>.</p>

<h2 id="the-actual-lesson">The actual lesson</h2>

<p>A git tag and <code class="highlighter-rouge">package.json</code>’s version field are two independent sources of truth for “what version is this,” and nothing enforces they agree until the registry rejects the mismatch at the very end of a CI run. A pre-publish step that just asserts the two match before <code class="highlighter-rouge">npm publish</code> is allowed to run would have turned both of these into a clear, immediate CI failure instead of a 403 discovered after the fact – worth adding next time I touch this workflow.</p>

<h2 id="try-it">Try it</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @snapeos/homebridge-samsung-soundbar-local
</code></pre></div></div>

<p>Repo’s at <a href="https://github.com/andrew-snape/homebridge-samsung-soundbar-local">andrew-snape/homebridge-samsung-soundbar-local</a>, full credit to <a href="https://github.com/ZtF/hass-samsung-soundbar-local">ZtF’s Home Assistant integration</a> for cracking the protocol first.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="projects" /><category term="homelab" /><category term="homebridge" /><summary type="html"><![CDATA[The soundbar in the lounge does AirPlay 2 fine, but it has never once shown up in the Home app’s “Add Accessory” flow – Samsung just never built HomeKit support in, and the official path to controlling it from a phone is the SmartThings app, with its own account and its own cloud round trip for a command as simple as “turn down the volume.” I didn’t want a second smart-home ecosystem for one device, so this turned into @snapeos/homebridge-samsung-soundbar-local – a Homebridge plugin for local, no-cloud control of D-series-and-later Samsung soundbars, verified against my own HW-Q930D.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/samsung-soundbar-homekit-plugin.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/samsung-soundbar-homekit-plugin.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running Homebridge for an All-Apple House</title><link href="https://andrew-snape.github.io/2026/08/13/running-homebridge-for-an-all-apple-house/" rel="alternate" type="text/html" title="Running Homebridge for an All-Apple House" /><published>2026-08-13T00:00:00+10:00</published><updated>2026-08-13T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/13/running-homebridge-for-an-all-apple-house</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/13/running-homebridge-for-an-all-apple-house/"><![CDATA[<p>Every device in this house that talks to a phone talks to an iPhone. Apple TVs for video, HomePods for audio, and the front door, the garage, and the temperature sensors around the house already running as native HomeKit accessories – so the moment I started adding devices that don’t speak HomeKit on their own, the only sane goal was still getting every one of them into the same Home app rather than juggling a separate app per brand. <a href="https://homebridge.io">Homebridge</a> is what makes that possible for the devices that don’t speak HomeKit natively, and it’s been running on the NAS for a while now, quietly bridging things Apple never built official support for.</p>

<h2 id="why-bother-with-a-bridge-at-all">Why bother with a bridge at all</h2>

<p>HomeKit accessories fall into two camps: things that speak the protocol directly (a handful of light bulbs and plugs, if you buy carefully), and everything else. “Everything else” is most of the smart home market – security cameras, doorbells, garage door controllers, TVs – and normally that means a separate app, a separate account, and no way to put that device in a Siri shortcut or a Home automation next to anything else.</p>

<p>Homebridge sits in the middle: it’s an open-source Node process that pretends to be a real HomeKit bridge (the same HAP protocol a genuine Apple-certified accessory speaks), backed by a plugin per device or ecosystem. From the Home app’s point of view, everything behind it looks like any other HomeKit accessory – same automations, same Siri phrases, same “everyone in the house can control it” behaviour, no separate login required for anyone else in the family.</p>

<h2 id="whats-actually-running">What’s actually running</h2>

<p>The container itself is unremarkable – a single Docker service on the NAS:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">homebridge</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">homebridge/homebridge:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">homebridge</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s">host</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">TZ=Australia/Melbourne</span>
      <span class="pi">-</span> <span class="s">PUID=1026</span>
      <span class="pi">-</span> <span class="s">PGID=101</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/volume1/config/homebridge:/homebridge</span>
    <span class="na">logging</span><span class="pi">:</span>
      <span class="na">driver</span><span class="pi">:</span> <span class="s">json-file</span>
      <span class="na">options</span><span class="pi">:</span>
        <span class="na">max-size</span><span class="pi">:</span> <span class="s2">"</span><span class="s">10m"</span>
        <span class="na">max-file</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3"</span>
</code></pre></div></div>

<p><code class="highlighter-rouge">network_mode: host</code> isn’t optional here – HomeKit pairing and discovery run over mDNS/Bonjour, which is multicast traffic that doesn’t survive Docker’s normal bridged networking. Homebridge has to sit directly on the LAN to be discoverable by the Home app at all. <code class="highlighter-rouge">PUID</code>/<code class="highlighter-rouge">PGID</code> match the NAS user that owns the config volume, and everything that actually matters – accessory config, cached pairings, plugin settings – lives under that one bind-mounted <code class="highlighter-rouge">/homebridge</code> directory, so the container itself is disposable.</p>

<p>On top of that base image, four plugins are doing the real work:</p>

<ul>
  <li><strong><a href="https://github.com/dgreif/ring">homebridge-ring</a></strong> – the Ring doorbell, cameras, and alarm system, all showing up as native HomeKit accessories. It’s configurable down to a fine grain: I’ve got it hiding the extra light-group and siren switches Ring’s own integration exposes by default, so the Home app shows what’s actually useful instead of a wall of redundant toggles.</li>
  <li><strong><a href="https://github.com/homebridge/homebridge-config-ui-x">homebridge-config-ui-x</a></strong> – the web dashboard for managing the whole install: plugin config, logs, restarts, without hand-editing JSON over SSH every time.</li>
  <li><strong>My own Centurion garage door plugin</strong> – the door itself, covered in more depth in <a href="/2026/08/11/forking-a-homebridge-garage-door-plugin/">the post on forking and rebuilding it</a>. Talks to the door controller’s local API directly rather than through any cloud service, camera stream included.</li>
  <li><strong><a href="https://github.com/homebridge-plugins/homebridge-matter">homebridge-matter</a></strong> – bridging Matter-native devices in as well, so Matter and classic HomeKit accessories end up in the same Home app without caring which protocol each one actually speaks under the hood.</li>
</ul>

<p>There’s a fifth plugin, an Xbox integration, sitting in the config but disabled – turned out controlling a game console from the Home app wasn’t actually useful day to day, so it’s parked rather than uninstalled in case that changes.</p>

<h2 id="child-bridges-and-why-each-accessory-gets-its-own">Child bridges, and why each accessory gets its own</h2>

<p>The one config detail worth calling out: every platform in Homebridge’s <code class="highlighter-rouge">config.json</code> gets its own <code class="highlighter-rouge">_bridge</code> block – its own HAP username and port, run as an independent child process rather than all sharing the main bridge. Homebridge does this by default now because it isolates failure: if the Ring plugin throws and its child bridge falls over, the garage door and the Matter bridge keep running and stay responsive in the Home app. Losing one integration used to mean losing all of them, back when everything ran as accessories on a single bridge process. Each child bridge also gets its own Matter port since Homebridge 2.0 added native Matter support – meaning the same accessories can, in principle, be exposed to a Matter controller too, not just HomeKit.</p>

<h2 id="was-it-worth-it">Was it worth it</h2>

<p>For an all-Apple household specifically, yes, without much hesitation. Nobody in the house has to remember which app the garage door lives in versus the doorbell versus the alarm – it’s all just Home, all controllable by anyone with an iPhone on the family’s Apple ID, all available to Siri and to automations that mix accessories from completely different vendors in the same scene. The tradeoff is the one that comes with any self-hosted piece of infrastructure: if the NAS or the container is down, so is smart home control until it’s back up. For devices with a local API – the garage door being the clearest example – that’s a small risk since there’s no cloud dependency in the loop at all. For the Ring side, cloud-dependent either way, so Homebridge being briefly offline doesn’t make things meaningfully worse than Ring’s own app being down would.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="homebridge" /><summary type="html"><![CDATA[Every device in this house that talks to a phone talks to an iPhone. Apple TVs for video, HomePods for audio, and the front door, the garage, and the temperature sensors around the house already running as native HomeKit accessories – so the moment I started adding devices that don’t speak HomeKit on their own, the only sane goal was still getting every one of them into the same Home app rather than juggling a separate app per brand. Homebridge is what makes that possible for the devices that don’t speak HomeKit natively, and it’s been running on the NAS for a while now, quietly bridging things Apple never built official support for.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/running-homebridge-for-an-all-apple-house.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/running-homebridge-for-an-all-apple-house.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Automating My Media Library with FileFlows</title><link href="https://andrew-snape.github.io/2026/08/11/fileflows-automated-media-transcoding/" rel="alternate" type="text/html" title="Automating My Media Library with FileFlows" /><published>2026-08-11T00:00:00+10:00</published><updated>2026-08-11T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/11/fileflows-automated-media-transcoding</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/11/fileflows-automated-media-transcoding/"><![CDATA[<p>Storage keeps getting more expensive to add, not less – about 17TB in now, before RAID overhead, and every extra drive is a bigger ask than the last one. Long term the real fix is smaller files: re-encoding the older, wasteful parts of the library into H.265/HEVC (or AV1) instead of just buying more disks. I looked at doing this by hand a couple of times and gave up both times – manual ffmpeg batch jobs that didn’t talk to the rest of the setup and didn’t scale past “run it once on a folder and hope.”</p>

<p><a href="https://fileflows.com">FileFlows</a> is what finally stuck. It’s a self-hosted, Docker-deployable file processing tool with a drag-and-drop flow builder, and – the part that actually made it worth setting up – proper Intel Quick Sync support, using the same <code class="highlighter-rouge">/dev/dri</code> device passthrough as <a href="/2026/08/11/plex-docker-intel-quick-sync/">the Plex hardware transcoding setup</a>.</p>

<h2 id="what-the-main-flow-actually-does">What the main flow actually does</h2>

<p>Nothing fancy, deliberately – I’m running the manual settings rather than the paid automatic-optimisation tier for now:</p>

<ul>
  <li>Scan the existing library.</li>
  <li>If a file is already HEVC: leave the video alone, but still strip audio tracks that aren’t the show or movie’s actual language (unless its default language isn’t English, in which case that one stays), strip unnecessary subtitle tracks, rename, and update the metadata to match.</li>
  <li>If a file is still H.264: transcode it to HEVC – lossy, but with the same audio and subtitle cleanup applied either way.</li>
  <li>Speed set to about 3 (low end), quality around 6. That combination fits my library well: newer high-quality stuff usually already arrives as HEVC, so it never hits the transcode step at all, and most of what’s still H.264 is older – 90s and early-2000s material that was low quality to begin with, so a leaner encode isn’t giving up anything that wasn’t already gone.</li>
  <li>Audio stays as AC3/EAC3 rather than getting converted down to AAC – I tried that, and the loss was more noticeable than expected, enough to actually matter. So audio re-encoding isn’t part of the flow.</li>
</ul>

<h2 id="where-it-sits-relative-to-sonarr-and-radarr">Where it sits relative to Sonarr and Radarr</h2>

<p>FileFlows runs against the library after the fact rather than sitting in front of the import. I could point it at the downloads folder (alongside SABnzbd) so new files get converted before Sonarr and Radarr ever import them, but I’ve deliberately not done that – sometimes I just want the file available immediately, not queued behind a transcode. Letting FileFlows work through the library in the background gets the same result without making a new download wait on it.</p>

<h2 id="two-more-flows-for-the-smaller-libraries">Two more flows, for the smaller libraries</h2>

<ul>
  <li><strong>Audio</strong>: FLAC downloads (16 or 24-bit, mostly off slskd) get remuxed to Apple Lossless. Not for any technical reason over keeping FLAC – purely because ALAC imports into Apple Music without friction, and everything I own is Apple hardware.</li>
  <li><strong>Audiobooks</strong>: individual MP3/AAC chapter files from Audiobookshelf and Calibre-Web get merged into a single M4B, which is what actually gives proper chapter markers and metadata instead of a folder of identically-named tracks.</li>
</ul>

<p>Video files also get repackaged from MKV into MP4 as part of the main flow. That loses a bit of what MKV can do – multiple embedded subtitle tracks and richer metadata mostly – but MP4 plays cleanly on everything I actually own, which matters more day to day than the features I’d be keeping.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="fileflows" /><summary type="html"><![CDATA[Storage keeps getting more expensive to add, not less – about 17TB in now, before RAID overhead, and every extra drive is a bigger ask than the last one. Long term the real fix is smaller files: re-encoding the older, wasteful parts of the library into H.265/HEVC (or AV1) instead of just buying more disks. I looked at doing this by hand a couple of times and gave up both times – manual ffmpeg batch jobs that didn’t talk to the rest of the setup and didn’t scale past “run it once on a folder and hope.”]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/fileflows-automated-media-transcoding.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/fileflows-automated-media-transcoding.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Getting My Garage Door into HomeKit: Forking and Rebuilding a Homebridge Plugin</title><link href="https://andrew-snape.github.io/2026/08/11/forking-a-homebridge-garage-door-plugin/" rel="alternate" type="text/html" title="Getting My Garage Door into HomeKit: Forking and Rebuilding a Homebridge Plugin" /><published>2026-08-11T00:00:00+10:00</published><updated>2026-08-11T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/11/forking-a-homebridge-garage-door-plugin</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/11/forking-a-homebridge-garage-door-plugin/"><![CDATA[<p>My garage door is a <a href="https://www.cgdoors.com.au">Centurion</a> – an Australian brand with its own smartphone app and a local API baked into the door’s controller. I run <a href="https://homebridge.io">Homebridge</a> for everything else in the house (it’s the open-source bridge that exposes non-HomeKit smart devices to Apple’s Home app), so getting the door to show up there properly, camera and all, turned into its own small project.</p>

<h2 id="the-starting-point">The starting point</h2>

<p>There was already a plugin for this: <a href="https://github.com/KieraDOG/homebridge-cgd-garage-door">KieraDOG/homebridge-cgd-garage-door</a>, built by Long Zhao. It talked to the door’s local API, exposed open/close, the lock mode, and the courtesy light as a Homebridge platform, and worked. But it hadn’t been touched in a while, it predated Homebridge 2.0 and modern Node, and as an outside contributor I have no publish access to push new versions of the original npm package. Rather than run a stale plugin forever or start from nothing, I forked it.</p>

<h2 id="catching-up-then-diverging">Catching up, then diverging</h2>

<p>The fork started boring on purpose: merge in everything from upstream <code class="highlighter-rouge">main</code> first, so the history stays honest about what’s actually new. From there the first real change was Homebridge 2.0 and Node 20+ support – table stakes for a plugin to keep working on a current Homebridge install.</p>

<p>The bigger step was migrating the whole plugin to the modern ESM format Homebridge’s own plugin template now expects: <code class="highlighter-rouge">"type": "module"</code> in package.json, TypeScript’s <code class="highlighter-rouge">nodenext</code> module resolution, <code class="highlighter-rouge">export default</code> instead of the old CommonJS <code class="highlighter-rouge">export =</code>, explicit <code class="highlighter-rouge">.js</code> extensions on every relative import (Node’s ESM resolver requires it, TypeScript won’t add it for you), and a modernized <code class="highlighter-rouge">config.schema.json</code>. Alongside that I brought in a proper local dev loop from the official plugin template – <code class="highlighter-rouge">npm run watch</code> now builds, links, and runs a real Homebridge instance against a throwaway test config, rebuilding on every save, instead of hand-wiring <code class="highlighter-rouge">npm link</code> each time.</p>

<p>That migration also forced the publishing question: the original <code class="highlighter-rouge">homebridge-cgd-garage-door</code> package name isn’t mine to publish to. It went out first under a scoped name, then got renamed again to <code class="highlighter-rouge">@snapeos/homebridge-centurion-garage-door</code> – “Centurion” is the brand actually printed on the hardware, and a much more findable search term than “CGD” (the app’s internal abbreviation). The Homebridge platform identifier itself (<code class="highlighter-rouge">CGDGarageDoor</code>, what goes in a user’s <code class="highlighter-rouge">config.json</code>) stayed untouched throughout, so nobody’s existing config broke across any of this.</p>

<h2 id="what-got-added-on-top">What got added on top</h2>

<p>Once the plugin was back on solid, current footing, a handful of real gaps were worth closing:</p>

<ul>
  <li><strong>A Stop button.</strong> The door’s local API has always supported <code class="highlighter-rouge">door=stop</code>, but nothing used it. HomeKit’s garage door service only models open/closed, so there’s no native slot for “stop” – it’s exposed as a separate momentary switch instead, the same workaround other garage plugins use for the same HomeKit limitation.</li>
  <li><strong>Real device identity and error state.</strong> The accessory’s serial number was a random HAP placeholder; it’s now the device’s stable hostname. More usefully, the door’s status API quietly reports its own error codes, which were being ignored entirely – a genuine device-side fault now surfaces properly as “Not Responding” in the Home app instead of looking like a normal, healthy door.</li>
  <li><strong>Native camera streaming.</strong> The Centurion controller has a built-in camera, served as an unauthenticated MJPEG stream on port 88. Previously that meant running a separate <code class="highlighter-rouge">homebridge-camera-ffmpeg</code> setup alongside this plugin just for the picture. It’s now a first-class <code class="highlighter-rouge">CameraController</code> on the garage door accessory itself – live view and snapshots, right there in Home, no second plugin. Getting ffmpeg to actually forward frames from a real MJPEG source (not a synthetic test pattern) surfaced two genuine bugs worth remembering: the stream has no reliable timestamps, so forcing an output framerate silently dropped every frame until <code class="highlighter-rouge">-use_wallclock_as_timestamps</code> was added on the input side; and libx264’s default lookahead buffering meant nothing reached the output at all on a short-lived session, fixed with <code class="highlighter-rouge">-preset ultrafast -tune zerolatency</code>.</li>
  <li><strong>A stuck update lock.</strong> A command that threw partway through left the plugin’s internal “is updating” flag permanently set, silently freezing status polling after a single failure – an easy one to miss until it happens to you at an inconvenient moment.</li>
</ul>

<h2 id="working-with-claude-code-on-it">Working with Claude Code on it</h2>

<p>Most of this – the ESM migration, the camera integration, the bug fixes – was built working with Claude Code, in the same spirit as <a href="/2026/08/06/classroom-word-games-with-claude-code/">the 6AS Word Games project</a> but for a very different kind of codebase: TypeScript, a real device protocol, and no way to “just reload the page” to check your work. The verification bar ended up higher than usual as a result – confirming the camera fix meant standing up a fake MJPEG server matching the real device’s exact response headers and capturing live RTP packets off a loopback SRTP receiver, not just watching <code class="highlighter-rouge">tsc</code> pass.</p>

<h2 id="try-it">Try it</h2>

<p>If you’ve got a Centurion door and run Homebridge, search “Centurion Garage Door” in the Homebridge UI, or:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @snapeos/homebridge-centurion-garage-door
</code></pre></div></div>

<p>Repo’s at <a href="https://github.com/andrew-snape/homebridge-cgd-garage-door">andrew-snape/homebridge-cgd-garage-door</a>, full credit to <a href="https://github.com/KieraDOG/homebridge-cgd-garage-door">Long Zhao’s original plugin</a> for doing the hard part first – reverse-engineering the door’s local API in the first place.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="projects" /><category term="homelab" /><summary type="html"><![CDATA[My garage door is a Centurion – an Australian brand with its own smartphone app and a local API baked into the door’s controller. I run Homebridge for everything else in the house (it’s the open-source bridge that exposes non-HomeKit smart devices to Apple’s Home app), so getting the door to show up there properly, camera and all, turned into its own small project.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/forking-a-homebridge-garage-door-plugin.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/forking-a-homebridge-garage-door-plugin.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Plex, Docker, and Getting Intel Quick Sync Working Again</title><link href="https://andrew-snape.github.io/2026/08/11/plex-docker-intel-quick-sync/" rel="alternate" type="text/html" title="Plex, Docker, and Getting Intel Quick Sync Working Again" /><published>2026-08-11T00:00:00+10:00</published><updated>2026-08-11T00:00:00+10:00</updated><id>https://andrew-snape.github.io/2026/08/11/plex-docker-intel-quick-sync</id><content type="html" xml:base="https://andrew-snape.github.io/2026/08/11/plex-docker-intel-quick-sync/"><![CDATA[<p>I’ve been running <a href="https://www.plex.tv">Plex</a> for years now, pretty much back to when it first existed. It’s moved through a small graveyard of hardware over that time – an old Mac mini, a couple of Raspberry Pis – and for a while now it’s lived on a Synology DS920+, running in Docker rather than as a bare Synology package.</p>

<p>That move to Docker is where I hit the first real snag: hardware transcoding. On the Mac minis it just worked – basic, since those were Core 2 Duo-era chips, but baked in and automatic. In a Docker container, none of that comes for free. The DS920+’s CPU supports Intel Quick Sync fine; the container just needs to actually be given access to it.</p>

<h2 id="getting-quick-sync-into-the-container">Getting Quick Sync into the container</h2>

<p>Two pieces, both well documented once you know to search for “Quick Sync” and “/dev/dri” together.</p>

<p>First: DSM resets the permissions on the Intel GPU’s device nodes back to root-only on every reboot, so the Synology-specific fix is a Task Scheduler boot script – Control Panel -&gt; Task Scheduler -&gt; Create -&gt; Triggered Task -&gt; User-defined Script, run as root, triggered on Boot-up:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod </span>666 /dev/dri/card0 /dev/dri/renderD128
</code></pre></div></div>

<p>Second, the container itself needs the device passed through, documented in <a href="https://github.com/plexinc/pms-docker">plexinc’s own docker image</a> as a <code class="highlighter-rouge">--device=/dev/dri:/dev/dri</code> docker run flag – the docker-compose equivalent:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">plex</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">plexinc/pms-docker</span>
    <span class="na">devices</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/dev/dri:/dev/dri</span>
</code></pre></div></div>

<p>Then it’s just a toggle inside Plex – Settings -&gt; Server -&gt; Transcoder -&gt; Show Advanced -&gt; Use hardware acceleration when available (this needs an active Plex Pass). With both pieces in place, transcodes that used to peg a CPU core run on the GPU instead.</p>

<h2 id="remote-access-and-the-thing-im-still-watching">Remote access, and the thing I’m still watching</h2>

<p>Plex Pass also makes remote access almost too easy – it’s on, working, and reachable from the internet with basically no setup on my end. That’s genuinely convenient, but it sits a bit oddly next to the rest of the homelab: everything else now runs behind Tailscale, with nothing else exposed directly. Plex is the one exception, and it’s something I’m keeping an eye on rather than something I’ve actually solved. Moving it behind Tailscale too would be easy for me – the hard part is everyone else. Family and friends who use it just want to press play, and asking them to install and log into Tailscale first is a bigger ask than it sounds.</p>

<h2 id="why-docker-over-the-native-package">Why Docker over the native package</h2>

<p>None of this is strictly necessary – Synology’s own Plex package supports hardware transcoding out of the box, no Docker required. I moved anyway, mostly for the boring reasons: backups are just the compose file and the config volume, a bad Plex update is a five-second rollback to the previous image tag instead of an uninstall/reinstall, and it’s one less thing tied to Synology’s own package ecosystem if the media server ever moves off this NAS.</p>

<p>Andrew</p>]]></content><author><name>Andrew Snape</name></author><category term="homelab" /><category term="plex" /><summary type="html"><![CDATA[I’ve been running Plex for years now, pretty much back to when it first existed. It’s moved through a small graveyard of hardware over that time – an old Mac mini, a couple of Raspberry Pis – and for a while now it’s lived on a Synology DS920+, running in Docker rather than as a bare Synology package.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://andrew-snape.github.io/assets/images/og/plex-docker-intel-quick-sync.png" /><media:content medium="image" url="https://andrew-snape.github.io/assets/images/og/plex-docker-intel-quick-sync.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>