<?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>Recyclarr on bismigel</title><link>https://bismigel.dev/tags/recyclarr/</link><description>Recent content in Recyclarr on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Sat, 24 Oct 2026 21:30:00 -0400</lastBuildDate><atom:link href="https://bismigel.dev/tags/recyclarr/index.xml" rel="self" type="application/rss+xml"/><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>