1 19 Self Hosted Media
Eric the IT Guy edited this page 2026-08-12 12:49:28 -05:00

Runbook 19: Self-hosted Media

Everything that sits on top of Plex (Runbook 16) and the arr box (Runbook 17) and turns "I have download tools" into "I ask for a book, an audiobook, a comic, or a YouTube channel and it lands on my device." This is the week I finished the media stack: the full ebook/audiobook/comics pipeline on itg-prd-book, quality automation and YouTube archiving on itg-prd-arr, and a long list of small, specific gotchas that are the real reason to keep this. Notes-to-self that double as a blog draft; verify the snippets against the itg-bootc and infra repos before publishing.

Key facts I keep needing. Two hosts do the work. On itg-prd-arr (vmid 202) the Runbook 17 stack plus four new leaf Quadlets: Recyclarr (headless, no web port), Mylar3 (8090, comics), Pinchflat (8945, YouTube subscriptions), and MeTube (8081, ad-hoc YouTube). On itg-prd-book (10.10.10.226, vmid 206) the reader-and-automation stack: Transmission (9091), LazyLibrarian (5299), Calibre-Web-Automated (8083), AudioBookShelf (13378), Komga (25600). All rootful podman Quadlets at PUID/PGID 1001, Restart=always. Storage mirrors the arr split: container config and SQLite on the local xfs disk dataBook at /var/lib/book, torrent scratch on the shared NFS downloads share (10.10.10.2:/mnt/fast-vmstore/downloads at /var/mnt/downloads), finished library on the media NFS (10.10.10.2:/slow-media/media at /var/mnt/media) in three new subtrees, audiobooks/, comics/, and youtube/, next to the movies and TV that Plex and the arr apps already share.

The shape: four pipelines, one library tree

Everything is one pattern repeated four times: a brain that finds and grabs, a downloader, and a reader that serves the finished files. Movies and TV run the arr pipeline into Plex (Runbooks 16 and 17). Books and audiobooks run through LazyLibrarian. Comics run through Mylar3. YouTube runs through Pinchflat. The brains are per media type, the readers are per format, and they all write into the one shared library tree on NFS so nothing is siloed.

The consolidation worth noting up front: Runbook 08 stood the book box up with Calibre, calibre-web, and Transmission. This week Calibre-Web-Automated (CWA) replaced calibre-web and the desktop Calibre container went away entirely. CWA reuses calibre-web's /config, so users and the Kobo sync token carried straight over, and it adds an auto-ingest watch folder plus automatic KEPUB conversion, which is exactly the glue the old manual "drop it in Calibre" step needed. Readarr is dead upstream, so LazyLibrarian is the brain for both ebooks and audiobooks, and it has a native MyAnonaMouse provider path (through Prowlarr) which is what makes the whole thing viable.

The book host stack

Five containers, each with a narrow job.

Transmission (lscr.io/linuxserver/transmission, 9091) is the torrent client, config on /var/lib/book/transmission, downloads to /var/mnt/downloads. It is the seed engine for MyAnonaMouse, so almost everything it holds is being seeded for ratio, which drives half the design decisions later in this doc.

LazyLibrarian (lscr.io/linuxserver/lazylibrarian, 5299) is the brain for ebooks and audiobooks. It carries DOCKER_MODS=linuxserver/mods:universal-calibre so it has calibredb and ebook-convert for metadata matching. It mounts /var/mnt/downloads (to see finished torrents), /var/lib/book/cwa-ingest (the ebook handoff to CWA), and /var/mnt/media/audiobooks (the audiobook destination).

Calibre-Web-Automated (docker.io/crocodilestick/calibre-web-automated, 8083) is the ebook library and reader, and the Kobo sync endpoint. It reuses /var/lib/book/calibre-web as /config, keeps the actual library on local disk at /var/lib/book/library, and watches /var/lib/book/cwa-ingest for new drops.

AudioBookShelf (ghcr.io/advplyr/audiobookshelf, 13378) serves audiobooks to the phone apps. Config and metadata on local disk, library read from /var/mnt/media/audiobooks on NFS. It does not use PUID/PGID; it runs as whatever User= you set, which turns into a gotcha below.

Komga (docker.io/gotson/komga, 25600) is the comics/manga reader with native Kobo and KOReader sync. Its config and database live on local disk at /var/lib/book/komga on purpose, because Komga's DB is as unhappy on NFS as the arr SQLite DBs are. It reads the comics library from /var/mnt/media/comics.

Comics: Mylar3 plus Komga, and where they live

Comics split across both hosts on purpose. The automation, Mylar3 (lscr.io/linuxserver/mylar3, 8090), lives on the arr box on arr.network (so it can reach SABnzbd and Prowlarr by container name), config on /var/lib/arr/mylar3, downloads to /var/mnt/downloads, and it writes finished comics to /var/mnt/media/comics. The reader, Komga, lives on the book box and reads that same /var/mnt/media/comics subtree over NFS. Mylar writes, Komga serves, one folder on the shared media NFS between them.

The comics source is not usenet and not MAM. It is GetComics, via Mylar's built-in DDL provider (enable DDL, then Enable GetComics). That is the standard, no-key comics source and it is the workhorse. Turn on Rename files in Mylar's Advanced Settings so issues land as Series NNN (Year).cbz, which is what Komga parses cleanly.

YouTube: Pinchflat plus MeTube

Two containers on the arr box doing two genuinely different jobs, and the reason there are two is a lesson in itself.

Pinchflat (ghcr.io/kieraneglin/pinchflat, 8945) is the subscription engine. Add a channel or playlist as a Source and it checks for new uploads on its own schedule and downloads them, with SponsorBlock integration, cutoff dates, title filtering, custom yt-dlp options, optional auto-delete of old content, and optional re-download later at better quality. Config on /var/lib/arr/pinchflat (local disk), output to /var/mnt/media/youtube, where Plex picks it up.

MeTube (ghcr.io/alexta69/metube, 8081) is the paste-a-URL grabber, and the only one of the two with an HTTP API: POST /add taking {"url": ..., "quality": ...}. State on /var/lib/arr/metube (local disk), output to /var/mnt/media/youtube/one-offs, kept in its own subtree so ad-hoc pulls never tangle with the per-channel folders Pinchflat manages. One Plex library covers both.

Neither is a superset of the other, which is why both exist. Pinchflat cannot download a single video by URL at all; its FAQ's workaround is to create an unlisted YouTube playlist, add the videos to it, and add that as a Source. It also has no documented REST API, so it cannot be driven by automation. MeTube has no subscriptions, no scheduling, no SponsorBlock, and no retention. Pinchflat is the library; MeTube is the utility and the future automation endpoint.

The real driver for Pinchflat is dropping YouTube Premium. Subscribe to the channels I actually watch, they land in Plex, no ads. SponsorBlock goes past what Premium does: Premium removes YouTube's ads but still leaves the host reading a sponsor script, and SponsorBlock strips those segments. What it does not replace is YouTube itself, so be honest about the boundary: no browsing, no recommendations, no search across the platform, and nothing for YouTube Music.

Storage discipline is not optional here. /slow-media sits at 92% (30T of 33T), and a channel's back catalog can be hundreds of videos. Set the cutoff date and the auto-delete retention rule when adding the first Source, not after discovering what a full archive costs.

One shared-plumbing note: mylar-comics-init.service from the comics work got generalized into arr-media-init.service, because the same job (mkdir the library dir on the media NFS as uid 1001, before the writer container starts) now covers comics/, youtube/, and youtube/one-offs/. It has to run as 1001 rather than root so it works against a root_squash export, and it has to run at all because podman would otherwise create the missing bind-mount target as root and the container, running as 1001, could not write into it.

Quality automation: Recyclarr

Recyclarr (ghcr.io/recyclarr/recyclarr:8, on arr.network, config on /var/lib/arr/recyclarr) syncs TRaSH-Guides quality profiles and custom formats into Radarr and Sonarr so I stop hand-tuning them. It runs headless in cron mode (CRON_SCHEDULE=@daily), no web UI, no published port.

The v8 gotcha: the official include: templates were removed. The v8-native way is quality_profiles: - trash_id: <id>, which auto-imports the profile's qualities, its score set, and its custom formats in one line. The config is in git, rendered by an arr-config ansible role, with API keys pulled from vault into a secrets.yml next to recyclarr.yml (referenced as !secret). Library-wide I settled on quality-first with a 1080p floor: Radarr on HD Bluray+WEB and UHD Bluray+WEB, Sonarr on WEB-1080p and WEB-2160p, and I let it delete lower-quality files it replaces because disk was getting tight.

Bazarr, tuned

Bazarr was already wired into Radarr and Sonarr (Runbook 17). The tuning that matters: turn on "Treat Embedded Subtitles as Downloaded" but also "Ignore Embedded PGS" and "Ignore Embedded VobSub," so image-based embedded tracks do not block Bazarr from fetching real text SRTs. Enable OCR Fixes and Common Fixes to clean what it downloads. Providers are a three-source spread (OpenSubtitles.com with Use Hash on and the AI/machine-translated toggles off, Subdl, and Gestdown for Addic7ed). Plex is connected via OAuth with "Refresh metadata after downloading subtitles" on so Plex re-reads the sidecar immediately. The one field that bites: General, Hostname. It defaults to the container ID, which rotates on every rebuild and is not reachable by the Plex server anyway, so the generated webhook URL is dead. Set it to the stable bazarr.itguyeric.com.

Indexers: MAM is the books workhorse, usenet is not

Prowlarr holds three indexers: DOGnzb (id 1), NzbPlanet (id 2), both usenet, and MyAnonaMouse (id 3), torrent. The single most useful thing I learned this week is to read an indexer's Search Capabilities before trusting it for a category. Both DOGnzb and NzbPlanet report Book Search: Not Supported. They are TV/movie/music indexers. So they are useless as a books source no matter how healthy they are, and LazyLibrarian book grabs through them return junk and fail. MAM (torrent) is the real books and audiobooks source, and its coverage dwarfs anything on usenet.

A related confusion I had to untangle for myself: a provider is not an indexer. UsenetExpress is a provider, the backbone you download articles from, and it retains all newsgroups subject to retention time. It does not "cover" a content category. What finds releases is the indexer. So "does UsenetExpress cover comics" is the wrong question; comic availability is an indexer plus posted-and-retained question, and comics on usenet are thin across the board. That is precisely why comics run on GetComics and books run on MAM.

The pipelines, end to end

Ebook. LazyLibrarian searches MAM through Prowlarr's torznab feed, grabs the torrent, and hands it to Transmission, which downloads into /var/mnt/downloads and keeps seeding. LazyLibrarian, with "keep original files" on, copies (not moves) the finished ebook into /var/lib/book/cwa-ingest. CWA's watcher ingests it into /var/lib/book/library, converts to KEPUB, and the Kobo syncs it over HTTPS. The original stays in /downloads seeding.

Audiobook. Same front half through LazyLibrarian and Transmission, but the audiobook destination is /var/mnt/media/audiobooks. AudioBookShelf reads that library in place (no ingest-and-delete like CWA), so LazyLibrarian is the library manager and ABS just serves it.

Comic. Mylar3 searches GetComics, downloads to its cache, post-processes and renames into /var/mnt/media/comics/Series (Year)/Series N (Year).cbz, and Komga reads it. Nothing touches Transmission or MAM.

YouTube. Pinchflat polls its Sources, downloads new uploads straight into /var/mnt/media/youtube/<Channel>/ with SponsorBlock applied, and Plex serves them. Ad-hoc grabs go through MeTube into /var/mnt/media/youtube/one-offs/. Neither touches Transmission, Prowlarr, or an indexer at all: yt-dlp pulls directly from YouTube.

The gotchas, in the order they bit

This is the part to actually remember.

AudioBookShelf could not bind its port. ABS threw a 502 through SWAG. The cause was not the proxy: the advplyr image listens on port 80 inside the container, but I run it as User=1001:1001, and a non-root user cannot bind a privileged port under 1024, so the container never came up and nothing was listening on the host. Every other service in the fleet listens on a high internal port; ABS was the only one on 80. Fix in the Quadlet: Environment=PORT=13378 plus PublishPort=13378:13378, so ABS listens on a port uid 1001 can bind.

Mylar found the comic and then crashed on a missing directory. GetComics returned results and Mylar picked one, then died with FileNotFoundError on /config/mylar/cache/html_cache/getcomics-*.html. Mylar does not reliably create that html_cache subdir. Fix, in code, in arr-appdirs.service: mkdir -p /var/lib/arr/mylar3/mylar/cache/html_cache and chown the tree to 1001. Related: the ComicVine API key must be entered and then Saved. An unsaved key works in-session (it was live long enough to add a series) and then vanishes on the next restart, and a search with no key does not error gracefully, it white-screens the tab because Mylar tries to iterate a None result.

Kobo sync and the SWAG server_name trap. CWA reused calibre-web's /config, so the Kobo token carried over unchanged. Sync still broke, because I renamed the subdomain from calibre-web.itguyeric.com to calibre.itguyeric.com and the Kobo still had the old host baked into .kobo/Kobo/Kobo eReader.conf (api_endpoint=https://.../kobo/<token>). Fix: update that line on the device to the new host. The deeper trap surfaced here: renaming the SWAG proxy-conf file is not enough. calibre.subdomain.conf still had server_name calibre-web.*; inside, so calibre.itguyeric.com matched no server block and fell through to the default server, serving the unstyled Hugo site. The tell that you are hitting the default server, not the app, is your own website rendering with no theme under the wrong hostname. Change the server_name inside the file, not just the filename.

LazyLibrarian: source, priority, and cross-host names. Three separate lessons. First, GoodReads is slow and floods Google Translate detecting the language of every foreign edition; for a prolific author it grinds for minutes. Switch the Primary Information Source to OpenLibrary (enable the OpenLibrary API first and save, then it appears in the dropdown; no key, and no rate limit, unlike keyless Google Books which caps at ~1000/day and will fail mid-add on a big author). Second, provider priority is backwards from the intuition: higher number wins. MAM at priority 1 kept losing to DOGnzb at 26, so set MAM to the highest number, not the lowest. Third, LazyLibrarian is on the book host and Prowlarr is on the arr host, so container names do not resolve across hosts; the torznab URL must be the FQDN http://itg-prd-arr.int.itguyeric.com:9696/3/api, not prowlarr:9696. A cross-host provider test failed until an arr reboot cleared a stuck state, so if the config is right and it still fails, reboot before you rebuild.

One more LazyLibrarian preference that gets the behavior I wanted: set authors to Active and leave the New Book Status default at Skipped. Active keeps the author's catalog current and searches only for titles I explicitly mark Wanted; Skipped means adding an author never auto-downloads the whole bibliography. That is the "track everything, download what I choose" model.

Seeding and MAM hit-and-run: never rm the seed directory. The book pipeline seeds torrents for MAM ratio, so /var/mnt/downloads/complete is a live seed directory, not a junk drawer. Deleting files there directly with rm does two bad things: Transmission flips those torrents to "missing files" and stops seeding, leaving orphans, and it risks MAM hit-and-run strikes on anything that has not met its seed obligation. Remove completed downloads through Transmission (transmission-remote -t <id> --remove-and-delete), which drops the data and the torrent together and only for torrents you choose. Better, let the tooling do it: LazyLibrarian is set to keep original files, not delete while seeding, and not delete completed tasks, so the only reaper is mam-purge.sh (hourly timer) which removes the oldest torrents only when free space drops under 50GB and only if they have seeded at least 14 days and hit ratio 1. The one time it is safe to rm on the host is genuinely old, obligation-met content from a previous era, and even then you clean the orphaned entries out of Transmission afterward.

Two YouTube state dirs that want to be on local disk, with two different escape hatches. MeTube's STATE_DIR defaults to /downloads/.metube, which would land its queue state on the media NFS; point it at a local volume instead. Pinchflat's docs say to set JOURNAL_MODE=delete when /config sits on a network share, which we do not need precisely because the arr storage rule already puts /config on local xfs. Same underlying rule as the SQLite split in Runbook 17, surfaced two different ways by two different apps.

Pick the Plex library type deliberately for YouTube. Channel content matched against a movie or TV agent produces nonsense titles. Other Videos with the Personal Media agent is the safe default; TV Shows with Personal Media works if you want channels to behave like series with episodes.

Verify the tool does the obvious thing before recommending it for its elegance. I chose Pinchflat first because it matched the architecture this whole runbook is built on: a brain writes into the shared tree, a reader serves it. That reasoning was sound and Pinchflat is a good tool. It also cannot do the single most basic thing the youtube-dl container it was replacing did, which is take a URL and download it, and it has no API to automate. Architecture fit is not a substitute for checking the feature list against what was actually asked for, and "it matches the pattern" is a seductive way to skip that check.

Recyclarr and Bazarr behaviors that look like bugs. Recyclarr has no web UI, so "there is no port" is correct, not broken; check its logs to confirm the daily sync. And a LazyLibrarian or Mylar search returning nothing after a click is usually not a failure, it is an async job with no immediate UI feedback; the answer is in the logs or the History tab, not the button.

Public versus LAN-only, and the clients

Readers go public, admin tools stay inside. CWA (calibre.itguyeric.com), AudioBookShelf, and Komga are exposed through Cloudflare grey-cloud plus SWAG proxy-confs, because Kobo sync and the phone apps need public HTTPS. LazyLibrarian, Mylar3, Pinchflat, and MeTube stay LAN-only behind the Runbook 15 split-DNS allowlist; they are admin surfaces with no reason to face the internet. MeTube especially, since it ships with no authentication at all, and Pinchflat's is optional (BASIC_AUTH_USERNAME / BASIC_AUTH_PASSWORD). Two SWAG details for this batch: the upstreams are cross-host, so set $upstream_app points at itg-prd-book.int.itguyeric.com (or the arr box) with the published port, not a container name (ABS is 13378, not 80), and CWA needs the sample's /opds/ and /kobo/ location blocks, the Kobo one carrying X-Forwarded-Host, or Kobo sync generates the wrong endpoint.

Clients that actually work: Kobo natively via CWA for ebooks and via Komga for comics. For AudioBookShelf on iOS, the official app is still in a full beta, so I use AudioBooth. For Komga on iOS, Panels (polished, native Komga) or Paperback (free, with the Komga extension). Video, including the YouTube library, stays on the Plex apps.

Verify, do not declare

Same rule as Runbook 17: "running" proves the container came up, not that the pipeline works. The events that actually proved each pipeline end to end:

  • Ebook: LazyLibrarian searched MAM, Transmission downloaded and began seeding, CWA showed "Fool Moon" within seconds, and it synced to the Kobo once the api_endpoint host was fixed.
  • Audiobook: Dungeon Crawler Carl audiobooks landed in /var/mnt/media/audiobooks and appeared in the ABS library.
  • Comic: "One World Under Doom" pulled from GetComics (the MEGA mirror was rate-limited, so Mylar fell back to PixelDrain), post-processed into /var/mnt/media/comics, and showed up in Komga as a series with issue 1.
  • YouTube: not yet proven. The Quadlets are committed and wired but have not been through a build-and-roll at the time of writing. The proof events will be a Pinchflat Source pulling a new upload into /var/mnt/media/youtube/<Channel>/ and appearing in Plex, and a MeTube POST /add landing a file in one-offs/. Until both happen this section is a claim, not a result, which is exactly the distinction this heading exists to enforce.

Follow-ups

Still open after this week: get the new SWAG proxy-confs (calibre, audiobookshelf, komga, plus the LAN-only lazylibrarian, mylar, pinchflat, and metube) into git instead of hand-placed on the web box, the same gap the rest of the fleet has. A real usenet books backup would need a book-capable indexer (NZBgeek or similar), since DOGnzb and NzbPlanet cannot do it; MAM stays primary regardless. Confirm MeTube actually honors STATE_DIR; if its state turns up under the media tree on first run it ignored the variable, and the fix is mounting the local volume at /downloads/.metube instead. Optional nicety: point Mylar's "Run script AFTER Post-Processing" at Komga's scan API (POST /api/v1/libraries/{id}/scan) so comics appear instantly instead of on Komga's six-hour scan. Further out, a Matrix room plus n8n as a router, taking a URL or a title in chat and calling the right API (Radarr, Sonarr, LazyLibrarian, or MeTube's /add) — which is the reason MeTube's API earns its container even though Pinchflat is the better library tool. And the honest final test is a from-scratch commission of both boxes once everything is pushed and baked, to prove the day-0 path the hand-configuration never fully exercised.