<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Mergerfs on bismigel</title><link>https://bismigel.dev/tags/mergerfs/</link><description>Recent content in Mergerfs on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Sun, 25 Oct 2026 04:10:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/mergerfs/index.xml" rel="self" type="application/rss+xml"/><item><title>The outage that looked like the internet blocking us</title><link>https://bismigel.dev/posts/2026-10-25-the-outage-that-looked-like-blocking/</link><pubDate>Sun, 25 Oct 2026 04:10:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-25-the-outage-that-looked-like-blocking/</guid><description/><content:encoded><![CDATA[<p>I spent an hour convinced that book indexers were blocking this homelab. They were innocent. Our own uplink was dead, and every application reported it differently.</p>
<p>This is the honest version of that hour, because the failure mode is common and the diagnosis is cheap — but only if you look at the right layer first.</p>
<h1 id="what-i-saw">What I saw</h1>
<p>The media node had stopped downloading books that clearly existed. Searches through Prowlarr returned nothing. A newly added indexer answered &ldquo;all selected indexers unavailable&rdquo;. Anna&rsquo;s Archive and two library mirrors returned `000` from `curl`.</p>
<p>So I built a theory: these sites block datacenter IPs and VPN egress, which is why book acquisition is hard on a homelab.</p>
<p>Every part of that theory was wrong.</p>
<h1 id="what-was-actually-true">What was actually true</h1>
<p>Two things, and neither had anything to do with the sites.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">wg show proton0
</span></span><span class="line"><span class="cl"><span class="c1">#   latest handshake: 37 minutes ago</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   transfer: 280 GiB received, 9 GiB sent</span></span></span></code></pre></div>
</div>
<p>The tunnel was up as an interface, the unit was `active`, `Result=success`. But the endpoint had stopped answering. Packets left, nothing came back.</p>
<p>And the reason nothing came back is the second fault:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip route get 205.142.240.210
</span></span><span class="line"><span class="cl"><span class="c1">#   205.142.240.210 dev proton0 table 51820 src 10.2.0.2</span></span></span></code></pre></div>
</div>
<p>The route to the VPN <strong>endpoint</strong> pointed into the VPN <strong>tunnel</strong>. A handshake can never complete that way: the packet has to reach the endpoint before the tunnel exists. Full bootstrap deadlock — and it looks exactly like an idle tunnel.</p>
<p>Proton had rotated the server, so the old IP was dead anyway. Two faults, one symptom.</p>
<p><strong>Both of those last two sentences were wrong</strong> — see the correction at the end. The old endpoint answered fine, and the real fault was a stale private key.</p>
<h1 id="the-check-i-should-have-run-first">The check I should have run first</h1>
<p>One command would have saved the hour:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">for</span> h in example.com registry.npmjs.org api.telegram.org<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%-22s &#39;</span> <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  curl -sS -o /dev/null --connect-timeout <span class="m">8</span> -w <span class="s1">&#39;%{http_code}\n&#39;</span> <span class="s2">&#34;https://</span><span class="nv">$h</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>Everything returned `000` with `conn=0.000000s`. When <strong>every</strong> destination fails, including ones nobody blocks, the problem is your egress. A site-specific block fails one host. A dead tunnel fails all of them.</p>
<p>That is now the first line of the runbook page: prove egress before blaming a site.</p>
<h1 id="the-fix-that-does-not-take-the-node-down">The fix that does not take the node down</h1>
<p>I made it worse before better. My first move was `systemctl restart wg-quick-proton0`, and the machine never came back: ARP answered, nothing above layer 2, and it needed a physical power-cycle. A node whose only route to the world is the tunnel it just tore down is a node you cannot reach to fix.</p>
<p>The correct move is to swap the peer in place, with no teardown:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">wg <span class="nb">set</span> proton0 private-key /tmp/new-key
</span></span><span class="line"><span class="cl">wg <span class="nb">set</span> proton0 peer &lt;server-pubkey&gt; endpoint 176.113.74.82:51820 <span class="se">\
</span></span></span><span class="line"><span class="cl">       allowed-ips 0.0.0.0/0 persistent-keepalive <span class="m">25</span></span></span></code></pre></div>
</div>
<p>(`::/0` was in this command originally. It should not have been: this peer does not route IPv6, so advertising `::/0` only added a second way to blackhole traffic.)</p>
<p>Plus the endpoint pin, so the deadlock cannot return:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ip route replace 176.113.74.82/32 via 192.168.2.1 dev eno1 table main</span></span></code></pre></div>
</div>
<p>Handshake inside ten seconds. `registry.npmjs.org` returned 200.</p>
<h1 id="what-the-sites-actually-do">What the sites actually do</h1>
<p>With real egress, retested:</p>
<table>
	<thead>
			<tr>
					<th>Target</th>
					<th>Result</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>`ebookbay.com`</td>
					<td><strong><strong>200</strong></strong></td>
			</tr>
			<tr>
					<td>`audiobookbay.lu`</td>
					<td><strong><strong>200</strong></strong></td>
			</tr>
			<tr>
					<td>`libgen.li`</td>
					<td>200</td>
			</tr>
			<tr>
					<td>`annas-archive.org`</td>
					<td>unreachable (fails at DNS from pods)</td>
			</tr>
			<tr>
					<td>`libgen.is`, `library.lol`</td>
					<td>dead mirrors</td>
			</tr>
	</tbody>
</table>
<p>So the one site I had declared &ldquo;blocking our egress&rdquo; answers fine. EBookBay is now reachable and still fails <strong>inside</strong> Prowlarr, which makes it a definition problem, not a network one. AudioBookBay answers but has no Prowlarr definition, so audiobooks need a definition and a torrent client — configuration work, not a wall.</p>
<p>The lesson generalises: &ldquo;the site blocks us&rdquo; is a claim that needs a control target before you write it down.</p>
<h1 id="monitoring-so-the-next-one-is-not-an-hour">Monitoring, so the next one is not an hour</h1>
<p>Nothing watched the tunnel. Disk alerts watched the pool, `NodeDown` watched the node, and both were green while no *arr could reach an indexer.</p>
<p>node<sub>exporter</sub> has no WireGuard collector, so the host writes the metrics itself: a 30 second systemd timer dumps handshake age and transfer counters into `/var/lib/node-exporter/textfile/`, which node-exporter now scrapes.</p>
<table>
	<thead>
			<tr>
					<th>Alert</th>
					<th>Condition</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>`WireGuardNoHandshake`</td>
					<td>handshake older than 5 minutes — critical</td>
			</tr>
			<tr>
					<td>`WireGuardHandshakeStale`</td>
					<td>older than 3 minutes for 10 minutes</td>
			</tr>
			<tr>
					<td>`WireGuardMetricsMissing`</td>
					<td>no metrics at all</td>
			</tr>
	</tbody>
</table>
<p>`persistentKeepalive` is 25s, so a healthy tunnel re-handshakes well inside the three minute window. The thresholds come from that number, not from taste.</p>
<h1 id="what-i-would-change">What I would change</h1>
<ul>
<li>Prove egress before writing &ldquo;the site blocks us&rdquo; anywhere. One `curl` loop with a control target is enough.</li>
<li>Never restart the tunnel interface on a host you cannot reach out-of-band. Pin the endpoint route, swap the peer live, persist afterwards.</li>
<li>Monitor the thing your application depends on, not the thing that is easy to measure. The pool had two alerts and the VPN had none, because disks are visible and uplinks are not.</li>
<li>Keep the honest number. The library is smaller than it looked for an hour, and the reason was ours.</li>
</ul>
<h1 id="correction-same-day-after-the-tunnel-came-back">Correction, same day, after the tunnel came back</h1>
<p>Three claims above did not survive the next six hours. They are corrected here rather than quietly edited, because the wrong version is the useful part.</p>
<p><strong><strong>1. The old endpoint was never dead.</strong></strong> I wrote that Proton had rotated the server. It had not. With the correct key, the old endpoint handshakes and passes traffic — verified after a reboot, before any repair ran. The real fault was a <strong>stale private key</strong>: after a rolled-back deploy, `&quot;/run/secrets/wg<sub>protonprivatekey</sub>&quot;` still held the previous key while the config expected the new one. WireGuard handshakes with a mismatched key and silently blackholes the payload. A handshake is not a working tunnel. Rotating the endpoint was unnecessary.</p>
<p><strong><strong>2. There were seven outages, not one, and the cause was mine.</strong></strong> Every deploy that changed the `wg-quick-proton0` systemd unit restarted it, and a `wg-quick` restart deletes and recreates the interface — which strands this node. The trigger was never the endpoint or the key: it was the attempt to make the fix permanent. The last one was my own commit dropping a `wants/after sops-nix.service` override; that alone was enough to change the unit.</p>
<p><strong><strong>3. `::/0` should never have been in `allowedIPs`.</strong></strong> This peer does not route IPv6. Advertising `::/0` to the tunnel only added a second way to blackhole traffic. It is `0.0.0.0/0` only. *(One exception, noted so nobody &ldquo;tidies&rdquo; it: the boot-seed block in the host file still lists `::/0` so its systemd unit stays byte-identical to the running generation; the reconciler enforces IPv4-only at runtime. Changing the seed is what restarts the tunnel and strands the node.)*</p>
<p>The fix that finally worked was not a better command; it was making the command unnecessary:</p>
<ul>
<li>the `wg-quick` block in Nix now deliberately quotes the <strong>previous</strong> endpoint, so activating a new generation does not change the unit and therefore does not restart the tunnel — verified across two deploys with `NRestarts=0` and an unchanged `ActiveEnterTimestamp`;</li>
<li>a timer-driven reconciler owns the live state and converges it in place with `wg set`, never a restart;</li>
<li>after any reboot the reconciler corrects the seed within ~90 seconds. Verified by rebooting the node and watching egress return with nobody at the keyboard.</li>
</ul>
<p>And one more, small and embarrassing: the reconciler&rsquo;s own data-path check demanded HTTP `200` from `https://1.1.1.1/`, which answers `301`. It reported &ldquo;no data path - endpoint/key mismatch&rdquo; on a healthy tunnel for hours. It now accepts any HTTP status and says `ok (handshake 9s, data path HTTP 301)`.</p>
<p>The durable output of this night is not a script but a document: `NETWORK.md` in the homelab repo. Every fact in it is backed by a command, and the eight invariants at the end are the rules I broke, one outage at a time.</p>
]]></content:encoded></item><item><title>Media quality preferences: size caps, quality profiles and the disk rebalance that forced them</title><link>https://bismigel.dev/posts/2026-10-24-media-quality-preferences-and-rebalance/</link><pubDate>Sat, 24 Oct 2026 21:30:00 -0400</pubDate><guid>https://bismigel.dev/posts/2026-10-24-media-quality-preferences-and-rebalance/</guid><description/><content:encoded><![CDATA[<p>The media pool reported 71% used. One disk inside it was at 99%. Nothing on a dashboard disagreed with anything else, until LazyLibrarian started writing book imports into its own config volume and calling it a success.</p>
<p>The cause was not a full disk. It was a preference: the stack had no rule about what a movie is allowed to weigh, and Radarr ships with no limit for 2160p at all.</p>
<p>This is the whole story: the disk, the rebalance, and the two declarative layers that now hold the policy.</p>
<h1 id="the-failure-99-on-one-branch-71-on-the-pool">The failure: 99% on one branch, 71% on the pool</h1>
<p>The pool is mergerfs over four branches:</p>
<table>
	<thead>
			<tr>
					<th>Branch</th>
					<th>Where</th>
					<th>Size</th>
					<th>Role</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>media-sda</code></td>
					<td>local SATA</td>
					<td>234G</td>
					<td>library, pinned</td>
			</tr>
			<tr>
					<td><code>media-nvme</code></td>
					<td>local NVMe</td>
					<td>135G</td>
					<td>library, pinned</td>
			</tr>
			<tr>
					<td><code>remote82-sda</code></td>
					<td>NFS from the other node</td>
					<td>458G</td>
					<td>mostly idle</td>
			</tr>
			<tr>
					<td><code>remote82-nvme</code></td>
					<td>NFS from the other node</td>
					<td>340G</td>
					<td>idle</td>
			</tr>
	</tbody>
</table>
<p>mergerfs used <code>category.create=epmfs</code>: a new file goes to the branch that already holds its directory, and free space is only compared between branches that hold it. The library directories existed on <code>media-sda</code> only, so every import landed there. Both other local disks were empty.</p>
<p>When <code>media-sda</code> dropped under mergerfs&rsquo; 4G <code>minfreespace</code>, creates failed with <code>ENOSPC</code> even though the pool had 788G free. That is the trap: <code>df /mnt/media</code> shows the union and stays calm, while the one branch that accepts writes is full.</p>
<p>The symptom was indirect. LazyLibrarian imported a book, could not write to <code>/books</code>, and fell back to its config volume:</p>
<pre><code>WARNING: eBook dir ['/books'] not writeable, using /config:
[Errno 28] No space left on device: '/books/ll_temp'
</code></pre>
<p>The book was in the database. The file was in the wrong place. The UI said the download worked.</p>
<h1 id="rebalancing-without-losing-anything">Rebalancing without losing anything</h1>
<p>The first fix is movement, not deletion. mergerfs merges reads, so a folder can move to another branch and keep serving from the same path.</p>
<p>The numbers from that day:</p>
<table>
	<thead>
			<tr>
					<th>Moved</th>
					<th>Size</th>
					<th>From</th>
					<th>To</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>9-1-1</code></td>
					<td>46G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>INVINCIBLE (2021)</code></td>
					<td>23G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>The Sopranos</code></td>
					<td>19G</td>
					<td>media-sda</td>
					<td>remote82-sda</td>
			</tr>
			<tr>
					<td><code>Marty Supreme (2025)</code></td>
					<td>69G</td>
					<td>media-nvme</td>
					<td>remote82-sda</td>
			</tr>
	</tbody>
</table>
<p>Result:</p>
<table>
	<thead>
			<tr>
					<th>Branch</th>
					<th>Before</th>
					<th>After</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>media-sda</td>
					<td>84% (36G free)</td>
					<td>46% (122G free)</td>
			</tr>
			<tr>
					<td>media-nvme</td>
					<td>91% (12G free)</td>
					<td>38% (80G free)</td>
			</tr>
			<tr>
					<td>remote82-sda</td>
					<td>4%</td>
					<td>40%</td>
			</tr>
	</tbody>
</table>
<p>Total pool content did not change: 318G before and after.</p>
<p>Three rules keep this safe:</p>
<ol>
<li>Never delete before the copy is verified. Compare the file list and the size of every file, then delete the source.</li>
<li>Use a tab separator when reading that list. Show names contain spaces, and <code>read -r path size</code> silently splits <code>Season 4</code> into two fields.</li>
<li>Expect a new file in the destination. Sonarr imported an episode during one copy. That is fine, the check only verifies the source list.</li>
</ol>
<p>A trap I walked into: I first tried to move a 69G movie onto <code>media-sda</code>. That was the wrong direction, it filled the disk I was trying to protect. I stopped it, removed the partial copy and sent it to <code>remote82-sda</code> instead.</p>
<h1 id="the-real-cause-no-preference-at-all">The real cause: no preference at all</h1>
<p>Moving data bought weeks. It did not explain how a single film reached 68G.</p>
<p>Radarr&rsquo;s stock quality definitions have <code>maxSize: null</code> for every 2160p class. No limit means a <code>Remux-2160p</code> is an acceptable release. One of them was 21% of the entire library.</p>
<p>The resolution was not the problem. The bitrate was. Two constraints decide what is worth storing here:</p>
<ul>
<li>remote streaming is capped at 12 Mbps, so anything above about 5.4 GB per hour cannot be delivered off the LAN at all;</li>
<li>a 2160p WEB-DL measures 125-310 MB/min, and a 2160p remux measures 450-750.</li>
</ul>
<p>So 4K is fine as WEB-DL. The remux class has no consumer in this house.</p>
<h1 id="layer-one-recyclarr-owns-quality-profiles">Layer one: recyclarr owns quality profiles</h1>
<p>Profiles decide which qualities may be downloaded. recyclarr reconciles them from a file in the GitOps repo, nightly.</p>
<p>Three profiles, all upgrade-enabled:</p>
<table>
	<thead>
			<tr>
					<th>Profile</th>
					<th>Allowed</th>
					<th>Cutoff</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>TV-1080p</code></td>
					<td>WEBDL / WEBRip / Bluray / HDTV 1080p</td>
					<td>WEBDL-1080p</td>
			</tr>
			<tr>
					<td><code>Movies-1080p</code></td>
					<td>same four</td>
					<td>WEBDL-1080p</td>
			</tr>
			<tr>
					<td><code>Movies-2160p</code> (opt-in per movie)</td>
					<td>WEBDL / WEBRip 2160p</td>
					<td>WEBDL-2160p</td>
			</tr>
	</tbody>
</table>
<p>No profile allows <code>Remux</code> or <code>Bluray-2160p</code>. 4K stays possible, but only in its sane form and only when assigned to a specific movie.</p>
<p>AVI is not a target. It is a 1992 container, usually with Xvid video: no modern subtitles, no reliable seeking index, and Jellyfin cannot direct-play it, so every playback transcodes for nothing. The library is already 100% MKV and it should stay that way. 1080p HEVC in MKV is the default; H.264 only when a client forces it.</p>
<p>Three things went wrong on the way:</p>
<ol>
<li><code>--dry-run</code> does not exist in the pinned image. The nightly job failed on every schedule until the log said <code>Unknown option 'dry-run'</code> and it became <code>--preview</code>.</li>
<li>The config I wrote was version 8 shaped. <code>recyclarr config create</code> pulls templates from the templates repository&rsquo;s master branch, which is already v8, while the image is 7.4.0. It answered <code>Property 'trash_id' not found</code>.</li>
<li>TRaSH presets are not a shortcut for size limits. Their <code>quality_definition</code> now sets <code>Max = 1000 (Unlimited)</code> for every quality. Applying it would have removed the useful 1080p cap and kept 2160p unlimited, which is the accident itself.</li>
</ol>
<h1 id="layer-two-opentofu-owns-the-size-caps">Layer two: OpenTofu owns the size caps</h1>
<p>recyclarr cannot express numbers, so sizes do not belong to it. OpenTofu has providers for both apps, and <code>quality_definition</code> is a resource.</p>
<p>Caps in MB per minute:</p>
<table>
	<thead>
			<tr>
					<th>Class</th>
					<th>Cap</th>
					<th>Roughly</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>720p</td>
					<td>22</td>
					<td>1.3 GB/h (stock)</td>
			</tr>
			<tr>
					<td>1080p</td>
					<td>35</td>
					<td>2.1 GB/h</td>
			</tr>
			<tr>
					<td>Remux-1080p / Bluray-1080p Remux</td>
					<td>60</td>
					<td>3.6 GB/h</td>
			</tr>
			<tr>
					<td>2160p</td>
					<td>250</td>
					<td>15 GB/h</td>
			</tr>
			<tr>
					<td>Remux-2160p / Bluray-2160p Remux</td>
					<td>400</td>
					<td>24 GB/h</td>
			</tr>
	</tbody>
</table>
<p>Those numbers come from measurement, not from taste: a 60-minute 1080p episode is about 30 MB/min, the 34G 2160p WEB-DL replacement is about 309, and the 68G remux is about 620.</p>
<p>My first draft said 20/55 MB/min. That was wrong. 55 would have rejected every real 4K WEB-DL, so the cap would have looked strict while quietly breaking 4K. The mistake is in the runbook now, with a note not to repeat it.</p>
<p>The rest of that layer is small but earns its place: root folders imported (not created, that would duplicate them) and a Telegram notification per app, failure signals only. Health issues, restores, app updates and manual interaction. Grab and import events stay off, because a TV library would otherwise send a message per episode.</p>
<h1 id="what-the-ownership-split-looks-like">What the ownership split looks like</h1>
<table>
	<thead>
			<tr>
					<th>Owner</th>
					<th>Objects</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ArgoCD</td>
					<td>deployment, PVC, service, ingress, image pin, network policy</td>
			</tr>
			<tr>
					<td>recyclarr</td>
					<td>quality profiles, custom format scores</td>
			</tr>
			<tr>
					<td>OpenTofu</td>
					<td>quality definition sizes, root folders, notifications</td>
			</tr>
			<tr>
					<td>nothing</td>
					<td>history, seeds, cookies (those live in the R2 backups)</td>
			</tr>
	</tbody>
</table>
<p>One object, one owner. This matters because two reconcilers touching the same object produce a loop: recyclarr would write a profile, Tofu would see drift and revert it, and both would be right.</p>
<p>Download clients and Prowlarr indexers are still imperative, in the setup script. Moving them needs a full config translation per object and duplicates are dangerous there, so they stay for a later slice.</p>
<h1 id="what-it-bought">What it bought</h1>
<ul>
<li>the 68G remux class cannot return through any profile;</li>
<li>2160p has a size limit for the first time;</li>
<li>all three branches sit between 38% and 46%, with 122G free on the busiest one;</li>
<li>a chapter of configuration that lived only in a SQLite file is now reviewable in a pull request.</li>
</ul>
<p>What I would do differently: set the caps before the rebalance. Moving 157G by hand was a symptom. The preference was the disease.</p>
]]></content:encoded></item></channel></rss>