TLMC v5.5 — acquisition, extraction, cataloguing & metadata enrichment#

Background (2026-08-22)#

The Touhou Lossless Music Collection v5.5 is a ~2,500-circle doujin archive distributed as one RAR per album. Acquiring it is the largest single media job the fleet has attempted: roughly ten times the file count of the current beets index, arriving through a 1 GB VPS exit node, in a container format nothing in the flake knows how to open.

Everything below was derived by parsing the .torrent metadata directly, so the numbers are exact rather than estimated. Findings that drive this runbook:

  • It is not audio files. 16,540 of the 17,656 entries are .rar — one per album, zero multipart. Extraction is a mandatory stage with no precedent anywhere in the flake. See §2.
  • It is lossy, despite the name. The payload is .m4a (AAC). The [CD-FLAC] / [WEB-FLAC] / [CDr-FLAC] markers in the filenames are the source medium of the original rip, inherited from the lossless collection’s naming convention — not a statement about what is inside the archive. A 46 MB median per album confirms it. Misreading this is the single easiest way to make a bad transcoding decision later.
  • It will not fit where torrents currently land. Measured 2026-08-22: kur/qbittorrent is 1023.5 GiB with 672.4 GiB available (35 % used; incomplete/ alone is 247 GiB). The payload is 922.5 GiB, so it is ~250 GiB short before the existing partials grow at all. And because XFS cannot shrink, growing that LV commits the space permanently. Two purpose-built LVs instead — see §0.
  • The metadata is the reason to do this at all, and beets will silently drop most of it. Circle, event, and 原曲 (original song) live in non-standard tag fields that MediaFile does not read. Without them the catalogue cannot answer “every arrangement of U.N. Owen Was Her?”, which is the entire point. See §3.
  • MusicBrainz autotagging must stay off. Doujin releases are largely absent from MB; the autotagger would either fail to match or overwrite curated Japanese metadata with something wrong. The beets module is already correct here (-A, write = false) — see beets.

Ground truth, for reference throughout:

Torrent name TLMC v5.5
Total size 990.5 GB decimal = 922.50 GiB (17,656 files, 14,760 × 64 MiB pieces)
Trackers public (nyaa.tracker.wf, opentrackr, open.stealth.si, …), not private
Archives 16,540 .rar, one per album, no multipart
Loose files 1,090 .m4a (899 in !TLMC_failed, 153 in !Misc_failed) + 26 images
Circle dirs 2,499, of which 1,093 have CJK names
Album naming [Circle]/YYYY.MM.DD [CATALOG] Title [Event].rar — 14,525 files, 771.9 GiB
!Misc/ 2,079 files, 146.2 GiB, different convention (see §2.3)
Release dates 1996.11.01 → 2024.11.16 (8,914 albums in the 2010s)
Largest archive 11.25 GiB; top-8 sum 32.4 GiB
Split 917.93 GiB of .rar + 4.55 GiB loose .m4a + 0.02 GiB images
Est. track count 130k–175k — plan on ~150k

Ordering: §0 → §1 → §2 → §3 are strictly sequential. §4, §5 and §6 are independent of each other and can run in any order once §3 has landed. §7 is deliberately last and deliberately open.


§0 — Preflight (do all of this before adding the torrent)#

0.1 Confirm the VG has room#

ssh ereshkigal 'sudo vgs kur && sudo lvs kur'   # expect ~9.1T free of 10.9T

Currently carved: telemetry 250G, paperless-pgdata 32G, jellyin-cache 250G, jellyfin-transcodes 200G, qbittorrent 1T ≈ 1.73 T. The two new volumes add 2.3 T, leaving ~6.8 T.

  • vgs kur reports at least 2.5 T free.

0.2 Create the two volumes (declaratively)#

requires: two tsunaminoai.storage.kurVolumes.volumes.* entries in hosts/x86_64-nixos/ereshkigal/default.nix, following the existing qbittorrent volume as the template.

LV Size fsType Mount Lifetime
tlmc-torrent 1.1T xfs /var/lib/tlmc-torrent disposablelvremove after the seed window
tlmc 1.2T xfs /srv/tlmc provisional library home (see §7)
kurVolumes.volumes.tlmc-torrent = {
  size = "1.1T";
  fsType = "xfs";
  mountPoint = "/var/lib/tlmc-torrent";
  owner = config.tsunaminoai.users.media.username; # nix-media-sops
  group = config.tsunaminoai.users.media.group;
  mode = "0775";
};

Both must be owned by the media user — qBittorrent runs as nix-media-sops (uid 100021), not root.

Why a separate disposable volume rather than growing kur/qbittorrent:

  • XFS cannot shrink. Space added to kur/qbittorrent stays committed to it forever. A dedicated LV is lvremove-able, which returns all 1.1 T to the VG in one command — which is the entire point of a bounded seeding window.
  • It keeps this job’s I/O off the volume the *arrs are actively using.
  • Rollback is trivial: nothing else writes there.
  • An ENOSPC on kur/qbittorrent would endanger the resume data of every other torrent on the box. Keep the whale in its own tank.

Why 1.1 T and 1.2 T rather than 1 T. LVM sizes are binary, so 1.1T is 1,126 GiB: the 922.5 GiB payload lands at 82 %, and the ~930 GiB library at 76 %. A flat 1T (1,024 GiB) would sit at 90.1 % on arrival and trip FilesystemAlmostFull (> 90 % for 15 m) permanently. The extra is the alert threshold, not padding.

Optional: stage the download on tempstorage instead. /dev/sda is a 2,794 GiB VG that is entirely free and referenced by no Nix at all. Putting the seed LV there splits reads (RARs off sda) from writes (library onto sdc), which roughly halves extraction wall-clock and keeps ~1.8 TiB of I/O off the spindle that also carries /nix, kur/telemetry and both Jellyfin cache LVs.

requires: tsunaminoai.storage.kurVolumes exposes vg as a single top-level option, so it cannot express a per-volume VG today. Either add that, or hand-write a fileSystems entry plus provisioner for the seed LV. Note this also makes tempstorage declared surface for the first time.

0.3 Verify the volumes are actually on the LVs — not on the root#

⚠️ This is the check that prevents the worst outcome in this runbook. kurVolumes mounts carry nofail, and the provisioner ordering bug documented in ereshkigal-storage-and-health.md §5 means a volume can silently land on the 225 GB btrfs root with a completely clean-looking boot. A 990 GB download into that will fill / and take the host down.

ssh ereshkigal 'findmnt -no SOURCE /var/lib/tlmc-torrent /srv/tlmc'
# expect two /dev/mapper/kur-* devices, NOT a btrfs root subvolume
ssh ereshkigal 'systemctl status kur-volumes-verify.service --no-pager'
ssh ereshkigal 'grep tlmc /var/lib/node-exporter/textfile/*.prom'   # kur_volume_mounted{...} 1
  • Both mounts resolve to /dev/mapper/kur-*.
  • kur-volumes-verify.service is green and kur_volume_mounted is 1 for both new volumes. The KurVolumeNotOnLV alert covers this ongoing.

0.4 Confirm an extractor exists where it will actually run#

unrar is on interactive tsunami’s PATH via modules/home-manager/core.nix, but it is not in environment.systemPackages — a systemd unit or anything running as nix-media-sops has no extractor at all. allowUnfree = true is already set on this host, so pkgs.unrar builds as-is and no tsunaminoai.nix.unfreePkgs entry is needed. (p7zip is the free alternative and is also already on tsunami’s PATH.)

  • ssh ereshkigal 'which unrar 7z' succeeds as tsunami.
  • The extraction unit in §2 references ${pkgs.unrar}/bin/unrar by full store path, not a bare unrar.

0.5 Confirm the egress path#

ssh ereshkigal 'sudo ip netns exec vpn curl -4 -s ifconfig.me'   # expect 45.61.186.219
ssh ereshkigal 'sudo tailscale-vpn status | head'
  • The namespace egresses via myon.

0.6 Get the .torrent out of the flake repo#

THMC.tor is 2.2 MB and sits untracked in the repo root. .gitignore does not cover *.tor, so it is one git add -A away from being committed into the flake permanently.

mv THMC.tor ~/  # or anywhere outside the working tree
  • The repo root is clean of *.tor.
  • *.tor added to .gitignore (see follow-up 12).

0.7 Check the exit node’s transfer allowance#

Every byte — 990 GB in, plus whatever seeding uploads — traverses myon, a BuyVM Las Vegas 1 GB / 20 GB slice. Nothing in the flake declares a transfer quota and there is no per-app rate limit anywhere, so this is a question for the BuyVM control panel, not the repo.

  • myon’s plan allowance comfortably exceeds ~2 TB of transit.

§1 — Download#

1.1 Add the torrent#

The WebUI on :8080 is a systemd-socket-proxyd listener that forwards into the vpn namespace — see netns exit. Add the torrent with the save path set to /var/lib/tlmc-torrent at add time.

⚠️ A torrent’s save path lives in its .fastresume, not in the config. Changing Session\DefaultSavePath affects only new torrents; an already-added torrent needs Set Location in the WebUI. Get it right on the way in.

⚠️ Setting the save path is not enough. This host runs Session\TempPathEnabled=true with Session\TempPath=/var/lib/qbittorrent/incomplete (verified 2026-08-22), so in-progress data goes to the old LV no matter what the save path says. With 672.4 GiB available there, the download dies of ENOSPC at roughly 73 % — and if it did fit, completion would become a cross-filesystem 922.5 GiB copy needing that much free on both LVs at once. Add the torrent paused, set the incomplete/download path onto the seed LV too, then confirm with df that bytes are landing there before letting it run.

Also confirm Session\Preallocation=false (already set on this host) so local usage tracks actual downloaded data rather than the full payload up front.

  • The torrent’s save path and its incomplete path both point at the seed LV.
  • df shows the seed LV growing and /var/lib/qbittorrent flat.
  • du -sh on the seed LV grows in step with reported progress.

1.2 Expect days, and expect gaps#

Operational realities that will look like faults and are not:

  • myon down ⇒ the torrent stalls, and nothing pages you. The in-namespace nftables kill switch permits only uid 0 on the veth, so qBittorrent’s packets are dropped rather than leaked. The unit does not fail, and it resumes on its own when the tunnel returns. myon’s Gatus endpoint is tagged tier = "intermittent" precisely because post-#268 a myon outage only stalls torrents — which means a multi-day stall is invisible unless you look.
  • There is no inbound peer port. 6881 exists only inside the namespace and is not forwarded, so we only ever reach peers we dial out to. Downloading is unaffected; seeding is slower than it would otherwise be, and ratio accrues gradually. Plan the seed window in weeks, not hours.
  • borgmatic stops and restarts qbittorrent-nox nightly, and at this size that is not merely log noise. The unit declares no TimeoutStopSec (modules/nixos/servarr/stack.nix), so systemd’s 90 s default applies. A 922.5 GiB torrent that cannot flush its resume data inside 90 s gets SIGKILLed, and the next start forces a full recheck — hours of pure rotational read, every night it happens. That alone can stop the download from ever finishing. Raising TimeoutStopSec is follow-up 1 and should land before §1, not after.

  • Progress checked periodically rather than relied on to alert.

1.3 Verify before extracting#

ssh ereshkigal 'du -sh /var/lib/tlmc-torrent'   # expect ~923 GiB (990.5 GB)

Force a recheck in the WebUI if anything looks short. At 64 MiB pieces a full recheck of 990 GB is disk-bound and slow — start it and leave it.

  • qBittorrent reports 100 % and the torrent is seeding.

§2 — Extract (990 GB of RAR → ~150k audio files)#

requires: an extraction tool plus a oneshot unit. Nothing in the flake does this today.

2.1 Shape of the job#

One archive per album, no multipart, so extraction is embarrassingly parallel and fully resumable. Target layout mirrors the torrent:

/srv/tlmc/<circle dir>/<album dir>/<tracks>.m4a

Run it the way beets-index.service runs: Type = oneshot, TimeoutStartSec = infinity, Nice = 19, IOSchedulingClass = idle, and RequiresMountsFor on both /var/lib/tlmc-torrent and /srv/tlmc. This is bulk background I/O competing with Jellyfin and the *arrs.

⚠️ Never stage extraction in /tmp. tmp-size-reaper deletes the oldest top-level entries whenever /tmp exceeds 10 % of the root fs (~22 GB on a 225 GB root), on an hourly timer, and it is age/size-based — not “is anything still using it”. It will eat a run mid-flight. Extract straight to /srv/tlmc.

2.2 Make it resumable from the first run#

Keep an append-only ledger keyed by archive path, exactly as pkgs/paperless-curate/lib.sh does with checkpoint.jsonl: one JSON object per archive recording {path, status, tracks} where status is ok / error / skipped. A resumed run reads the ledger into a skip set and never re-evaluates a completed archive. Test each archive before extracting it so a truncated download surfaces as error rather than as a half-extracted album.

  • Killing and restarting the run mid-way resumes rather than restarting.
  • The ledger’s ok count reaches 16,540.
  • error entries are reviewed, not just counted.

2.3 Four shapes the loop must handle#

  1. The main convention — 14,525 archives under [Circle]/, named YYYY.MM.DD [CATALOG] Title [Event].rar. 14,519 carry the leading date and 11,495 a catalog number, so both are parseable but neither is guaranteed.
  2. !Misc/ — 2,079 archives, 146.2 GiB, using a different convention: (C74)[Circle] Album {CATALOG} [CD-FLAC].rar, with the event as a leading parenthetical and the catalog in braces. Some are nested two levels deep under !Misc/!Unsorted/!New unsorted albums/. A parser written only against the main convention will mis-file all of these.
  3. !TLMC_failed/ and !Misc_failed/ — 1,052 loose .m4a with no archive and no album context; these are the tracks the collection’s own tagger could not place, so they have the worst metadata in the set. Copy them through as-is. Note one is literally named .wav.m4a — a dotfile, which globs will skip and find will not.
  4. The oversized archives — 11.25 GiB, 7.47, 5.76, 2.23, 1.57 GiB; 32.4 GiB in the top eight. They are fine sequentially but will dominate a parallel run’s tail.

Only 8 basenames collide across different circle directories, and since the circle directory is part of the destination path, the mirrored layout resolves all of them. Verify rather than assume.

2.4 Check the inner filenames on the very first archive#

The archive names are already known-safe: zero \:*?"<>|, longest component 190 bytes, no path over 255 characters, no leading or trailing spaces or dots. The collection deliberately substitutes full-width lookalikes — ×1901, ×561, ×473, ideographic space ×186 — which is a strong signal the contents follow the same discipline. It is not proof.

# after the first successful extraction
ssh ereshkigal 'find /srv/tlmc -name "*[\\:*?\"<>|]*" | head'   # expect empty
ssh ereshkigal 'find /srv/tlmc -regextype posix-extended -regex ".*/[^/]{256,}" | head'
  • Neither check returns anything.
  • Extracted total is ~923 GiB and the file count lands in the 130k–175k band.

§3 — Ground truth, then index#

3.1 Dump every raw tag before beets sees anything (irreversible if skipped)#

This is the actual preservation guarantee, and it is independent of beets: one JSONL record per file containing its complete tag set, straight from ffprobe -show_format -show_streams -print_format json. A few hundred MB for 150k tracks, diffable, and the thing that makes every later mistake recoverable.

Store it on /srv/tlmc and add it to the borgmatic source set — the extracted audio is re-downloadable, this file is the only copy of what the tags said on arrival.

  • Record count matches the extracted file count.
  • The dump is in a backup set, not only on the LV it describes.

3.2 Survey what the transcode actually kept — do not guess#

The doujin-specific fields (circle, event, 原曲, vocalist, arranger, lyricist) live in MP4 freeform atoms named ----:com.apple.iTunes:<NAME>, and whether they survived the transcode depends entirely on the tool that made it. The field names in the wild are inconsistent — CATALOG vs CATALOGNUMBER, ORIGINALTITLE vs ORIGINAL, original-song lists with different separators.

Sample ~50 albums spread across circles and decades, histogram the atom names actually present, and derive the field list from that. The tag dump from §3.1 already contains everything needed to do this offline.

Useful to know before designing anything: MediaFile 0.17 already covers arranger, lyricist, composer, catalognum and disctitle, so those come across for free. Only CIRCLE, EVENT, 原曲 and VOCALIST genuinely need custom media fields.

  • A concrete, evidence-backed field list exists before any plugin is written.
  • The fallback is known: if the atoms did not survive, the directory names still carry date / circle / album / catalog / event (§2.3), and the parser from §2 already extracts them.

3.3 Teach beets to read those fields#

requires: a beets plugin registering the surveyed fields via add_media_field() with MP4StorageStyle('----:com.apple.iTunes:<NAME>'), packaged through the nixpkgs pluginOverrides convention. Plus a plugins = [...] entry — modules/nixos/media/beets.nix currently hard-codes plugins = [].

They then become flexible attributes: queryable (beet ls circle:"…"), exportable, usable in path formats, and — because import.write = false — never written back to disk. This does not relax the read-only contract; the module’s copy/move/write invariants and issue #273 stand untouched.

3.4 Import#

requires: tsunaminoai.media.beets.libraryPathlibraryPaths (a list). beet import already accepts multiple roots and RequiresMountsFor already takes a list, so this is a small change.

⚠️ Set import.duplicate_action = "keep" first. TLMC is full of reissues, box sets and best-of compilations that collide on albumartist+album, and the module’s quiet_fallback = "skip" will send every one of them to import.log silently. Hundreds of albums can vanish without an error.

Import with -A (no autotag) as the module already does, and chunk by circle directory rather than pointing it at the root: import.incremental is directory-keyed, so a crash or a reboot resumes cleanly at album granularity.

Two scale notes for a ~150k-track library:

  • Locally on /srv/tlmc this is hours. The same import over the CIFS share would be days — the existing 16.6k-file index was already “hours-scale”.
  • Flexible attributes live in a separate key/value table, so circle: and originaltitle: queries will not be as fast as artist: at this row count. Fine for interactive use; do not put one in a hot path.

  • beet stats reports a plausible track count (16,628 + ~150k).

  • import.log reviewed for skips — expect it to be near-empty, not silent.
  • beet ls circle:"Alstroemeria Records" returns albums.

§4 — Enrichment: romanization and translation#

The goal is to make a Japanese-language collection searchable and browsable by someone reading romaji or English, without touching the original tags. Everything lands as beets flexible attributes plus a durable sidecar cache.

Cost is bounded by unique strings, not track count — dedupe first and the job shrinks by an order of magnitude. Follow the transport conventions already proven in pkgs/paperless-curate/lib.sh: format:"json" via ollama_chat_json, an ollama_require_model preflight so a missing model fails fast instead of producing 100k empty glosses, // default fallbacks at every field extraction, and proposals.jsonl → review → --apply with a checkpoint.jsonl resume.

Base URL is http://ereshkigal.armadillo-banfish.ts.net:11434 (the failover proxy). Anything requested through it must exist on both the M4 and mokou or failover silently degrades to missing-model errors; a Japanese-stronger M4-only model means bypassing the proxy, per the reprocessing runbook.

4.1 Tier 1 — closed sets, no LLM at all#

⚠️ Do not let a model near these. 「U.N.オーエンは彼女なのか?」 has an official English title — “U.N. Owen Was Her?” — and an LLM will paraphrase it.

  • 原曲 titles. ~1,000 canonical Touhou tracks, all with official English names. This is a curated lookup table, declared as data the way modules/nixos/containers/doc-pipeline/correspondents.nix declares tagVocabulary.
  • Event tags. ~20 regexes cover the whole vocabulary: C90 → Comiket 90, 例大祭13 → Reitaisai 13, M3-23, サンクリ → Sunshine Creation, 秋季例大祭 → Autumn Reitaisai. Deterministic, no inference.

This tier is also where the most valuable query in the collection comes from — “every arrangement of X” only works if 原曲 is normalized against a fixed list.

4.2 Tier 2 — romanize proper nouns#

1,093 of 2,500 circle names contain CJK, plus the vocalist / arranger / lyricist name space. The goal here is romanization, not translation: 幽閉サテライト is “Yuuhei Satellite”, not “Confined Satellite”.

Japanese proper nouns have irregular readings, so seed a table with the top ~200 circles by album count — they cover most of the collection and have community-canonical readings (豚乙女 = “Buta Otome”) — and let the model handle the tail. Record provenance (table vs llm) on every value so a guessed reading is always distinguishable from a known one.

4.3 Tier 3 — romaji plus an English gloss#

8,436 of 16,379 unique album titles contain CJK; track titles add roughly 80k–110k more unique strings. This is the expensive tier.

Batch ~20 titles per request. Serially it is ~55 hours; batched it is a few hours. Keep the sidecar cache keyed by a hash of the source string so the expensive work survives a library rebuild and a re-run costs nothing.

⚠️ jq’s ascii_downcase, used throughout the curate family for dedupe keys, is a no-op on kana and kanji. The dedupe key here needs NFKC normalization, which means Python, not jq. This is why the tool for this stage should follow pkgs/paperless-rag’s packaging rather than pkgs/paperless-curate’s.

4.4 Land it in the catalogue#

beet modify -W -y 'query…' circle_romaji='…'

⚠️ Keep -W, and never pass -w. beet modify writes tags to disk by default, but beets resolves that default from import.write via ui.should_write() — so the module’s write = false already suppresses it, as long as the pinned config is in play. That is the catch: the beet wrapper in modules/nixos/media/beets.nix supplies -c and BEETSDIR, and a raw beet from a nix shell has neither. -W is the belt to that braces.

  • Sidecar cache is in a backup set.
  • A spot-check of 20 romanizations against known-correct readings passes.
  • beet ls circle_romaji:yuuhei finds 幽閉サテライト.
  • Original tags on disk are byte-identical to the §3.1 dump.

§5 — AcoustID fingerprint submission#

Fingerprints are the one contribution that scales here. They are format-agnostic, so a lossy transcode is perfectly acceptable, and submitting them is welcomed rather than merely tolerated.

requires: plugins = ["chroma"] in the generated beets config, plus an AcoustID API key in sops.

No derivation override is needed: nixpkgs enables all builtin beets plugins in the package by default (disableAllPlugins ? false), and chroma already carries pyacoustid as a dependency and chromaprint on the wrapper’s PATH. The only blocker is that the module hard-codes plugins = [].

Honest accounting before committing to this:

  • The cost is a full CPU and I/O pass over ~150k files — fingerprinting is not incidental work. Run it nice’d, like everything else here.
  • The value is partial. Without MBIDs the submissions enrich the fingerprint index but do not link recordings to releases. That is still a real contribution; it is not the same as cataloguing the collection on MB.

Why release submission is out of scope#

Worth stating plainly so the question does not get reopened:

  • Doujin music is far better represented on VGMdb than on MusicBrainz.
  • MB style requires per-release artist credits, correct aliases, and sourcing. At a realistic ~15 minutes per release, 16,540 releases is roughly 4,000 hours of editing.
  • Bulk-importing scene metadata is against MB guidelines and gets reverted.

If specific albums are worth contributing, the path is Picard’s Add cluster as release on a hand-picked handful — not a pipeline. Note also that track lengths from an AAC transcode are close to but not exactly the CD’s, and no disc ID can be derived from them.

  • API key in sops and verified before the pass starts.
  • Submission pass runs nice’d and resumably.

§6 — Surfacing#

  • beets is the query engine and works as soon as §3 lands:
beet ls circle:"Alstroemeria Records"
beet ls originaltitle:"U.N. Owen Was Her?"    # after §4.1
beet stats
  • Jellyfin — think before adding it. Libraries are created in-app, and no Nix change is needed for a local path. But modules/nixos/media/jellyfin-provision.sh POSTs EnableInternetProviders: true, EnableRealtimeMonitor: true and AutomaticRefreshIntervalDays: 0 to every library it has not yet marked, on a daily timer. Pointed at 16,540 doujin albums that is a MusicBrainz/AudioDB scrape storm plus a live filesystem watcher, hunting metadata that does not exist. Either exclude TLMC from the provisioner or accept that the default answer here is beets + Navidrome only.
  • Navidrome can hold this — the blocker is the sandbox, not the config. Navidrome 0.63.2 (the pinned version) is multi-library: MusicFolder merely seeds row 1 of a library table, and extra libraries are admin-UI/DB objects rather than config keys. Verified in the shipped binary (library_artist, LibrariesAssigned, MusicFolderIds, insert into library). What actually blocks a second path is nixpkgs’ unit: it binds exactly one music path into the namespace, optional (cfg.settings ? MusicFolder) cfg.settings.MusicFolder, under RootDirectory=/run/navidrome. Any second library — local or on the share — scans as missing until that bind is added. modules/nixos/media/server.nix already extends this unit, so it is a two-line change (follow-up 10). Note the library row itself is non-declarative state in /var/lib/navidrome, which is borg-covered.
  • The weather channel’s music bed already wants this. The comment on ws4channels.music in hosts/x86_64-nixos/ereshkigal/default.nix reads “The motivating query is Touhou jazz; the library has no Touhou today” — this is the change that finally makes that query mean what it says.

  • beet stats and a handful of queries return sane results.

  • Jellyfin shows the library and plays a track.

§7 — Decide the final home (deliberately deferred)#

The local path in §0–§6 is the right way to get the collection usable regardless of where it ends up, and none of that work is wasted by a later move. This section is the one open branch, to be resolved after the collection is actually browsable and its value is known. Three outcomes:

  1. Stay on /srv/tlmc — the default, and cheapest. Two things to settle: voile is the backup target (BorgWarehouse runs there), so “on the NAS” would never have meant “backed up” either way; and more sharply, this host sets kurVolumes.diskoParity = true, which emits every volume into a format-only disko layout for clean rebuilds. A nixos-anywhere reinstall would happily mkfs.xfs the TLMC LV. The data is inside the “recreate from scratch” set, so either accept “re-download if lost” in writing, or back up the §3.1 tag dump at minimum (930 GiB of re-downloadable audio in a borg archive on a NAS with 3.7 TiB free is not sensible).
  2. Promote to the share, as .../share/Touhou/ — a sibling of Lidarr’s Music root, never inside it. voile has 3.7 TiB available (81 % used), so ~930 GiB is a fifth of the remaining headroom on the only redundant, backup-hosting storage in the fleet. If you do it, move it once with rsync -aHS --partial --append-verify rather than extracting over SMB — one resumable writer, not 16,540 unrar processes. Then re-point beets.libraryPaths; existing rows carry absolute paths, so plan on a beet update or a fresh import of the new root.
  3. Add it to Navidrome — not blocked, just unbuilt. 0.63.2 is multi-library; what is missing is one BindReadOnlyPaths entry on the unit (see §6). This is orthogonal to 1 and 2: it works for a local path or a share path, so it can be done without deciding either.

⚠️ Whichever way this goes, do not extract or rsync into Lidarr’s root folder. 2,499 unmatched circle directories inside it become 2,499 phantom artists that Lidarr will try to manage.

  • Decision recorded here, with a date, so this section stops being open.

Landmines#

Condensed to the parts with lasting value.

XFS cannot shrink. Any space added to an existing XFS volume is committed to it permanently. That is the whole reason the download gets a dedicated, disposable LV rather than a grown kur/qbittorrent — a bounded seeding window is only meaningful if the space actually comes back.

A kurVolumes mount can silently land on the btrfs root. The mounts carry nofail and the provisioner ordering bug is documented in ereshkigal-storage-and-health.md §5. With a 990 GB download pointed at it, a mount that quietly didn’t happen fills a 225 GB root and takes the host down. Check findmnt before starting, and lean on kur-volumes-verify.service / KurVolumeNotOnLV thereafter.

/tmp is reaped on size, not on use. tmp-size-reaper deletes the oldest top-level entries once /tmp passes 10 % of the root fs (~22 GB), hourly, regardless of whether something is writing them. Long extractions staged there die halfway with no obvious cause.

The format marker in the filename is the source medium, not the payload. [CD-FLAC] on a TLMC archive means the original rip was a FLAC of a CD. The file inside is AAC. Every downstream decision about transcoding or quality has to start from the payload.

quiet_fallback = "skip" plus duplicates loses albums silently. Set import.duplicate_action = "keep" before importing a collection this full of reissues and box sets, or hundreds of albums go to import.log with no error and no obvious gap.

beet modify writes tags by default, and the config only protects you inside the wrapper. modify writes to disk unless told otherwise, taking its default from import.write via ui.should_write() — which the module sets to false, so the invariant does hold for the wrapped beet. But that protection lives entirely in the pinned config: the wrapper supplies -c and BEETSDIR, and a raw beet invoked from a nix shell has neither. Pass -W explicitly on every modify so the guarantee does not depend on which binary you happened to run.

jq’s ascii_downcase is a no-op on CJK. Every dedupe/normalization pattern inherited from the curate shell family is silently ineffective on kana and kanji. NFKC in Python is the fix.

myon stalls are invisible. The kill switch means a dead exit node stalls torrents without failing the unit, and myon’s Gatus tier is intermittent — non-paging by design, because post-#268 host egress is direct and only acquisition is affected. A week-long stall looks exactly like a slow swarm.

There is no inbound peer port. 6881 lives only inside the namespace, unforwarded. Seeding reaches only peers we dial, so ratio accrues slowly — size the seed window accordingly.

borgmatic restarts qBittorrent nightly. Expected, guarded, and it only restarts what it stopped. Over weeks it is a lot of log noise that is not a fault.

Setting a torrent’s save path does not move its incomplete data. Session\TempPathEnabled=true sends in-progress pieces to the global Session\TempPath, so a save path on a new volume still fills the old one, then demands a full-size cross-filesystem copy at completion. Set both, and verify with df rather than with the WebUI.

qbittorrent-nox has no TimeoutStopSec. systemd’s 90 s default plus a nightly borgmatic stop means a large torrent gets SIGKILLed before it can flush resume data, and pays for it with a full recheck on every restart. Harmless at today’s sizes; potentially terminal at 922.5 GiB.

diskoParity = true puts local volumes in the reformat set. kurVolumes emits a format-only disko layout so rebuilds are clean, which means a nixos-anywhere reinstall will mkfs any LV declared there. Local storage on this host is explicitly “recreate from scratch” — do not let a family-visible library quietly become the exception without saying so.

Lidarr’s root folder is the Music share, and it rescans on a timer. Verified on the live DB: root folder …/share/Music/, a daily RescanFoldersCommand (interval 1440), and WatchLibraryForChanges left at its true default, which puts a recursive FileSystemWatcher on the tree. Anything extracted inside that root gets adopted as phantom artists and triggers back-to-back full-tree walks over CIFS while the extractor is still writing. RenameTracks is currently 0, so files are not moved today — but that is one UI checkbox away from renaming 150k files into {Artist}/{Album} ({Year})/… and destroying the circle/catalog/event structure that makes the collection navigable.

.torrent files are not gitignored. THMC.tor was sitting untracked in the repo root, one git add -A from a 2.2 MB blob in the flake’s history.


Follow-ups (code, not covered by this runbook)#

Ordered by when they block a step, not by size.

  1. TimeoutStopSec on qbittorrent-nox (300–600 s) in modules/nixos/servarr/stack.nix. Blocks §1 — without it a nightly borgmatic stop can SIGKILL the client into a full recheck. Worth doing regardless of TLMC.
  2. kurVolumes.volumes.tlmc-torrent (1.1T) and .tlmc (1.2T) on ereshkigal. Blocks §0.
  3. Per-volume vg in tsunaminoai.storage.kurVolumes, only if the seed LV goes on tempstorage (§0.2) — vg is currently a single top-level option.
  4. An extraction tool plus oneshot unit with a resumable ledger. Blocks §2.
  5. tsunaminoai.media.beets.libraryPathlibraryPaths (list) in modules/nixos/media/beets.nix. Blocks §3.4.
  6. import.duplicate_action = "keep" in the generated beets config. Blocks §3.4, and silently loses albums if skipped.
  7. A beets media-field plugin for CIRCLE / EVENT / 原曲 / VOCALIST; field list fixed by the §3.2 survey. Blocks the queries that justify §3.
  8. A translation tool (Python — NFKC and batching are past bash+jq), wired like paperless-curate: --set-default wrapper, STATE_DIR, dry-run/apply. Blocks §4.
  9. plugins = ["chroma"] and an acoustid/api-key sops entry. Blocks §5.
  10. One BindReadOnlyPaths entry for the library path on systemd.services.navidrome in modules/nixos/media/server.nix — the nixpkgs unit binds only MusicFolder. Needed for Navidrome to see the collection at all (§6). The library row itself is created in the admin UI.
  11. Exclude TLMC from modules/nixos/media/jellyfin-provision.sh, or adopt “beets + Navidrome only” as policy (§6).
  12. Add *.tor to .gitignore.
  13. Back up the §3.1 tag dump via the borgmatic source_directories set — and settle the diskoParity reformat question in §7 branch 1 before treating /srv/tlmc as durable.

  • beets — the catalogue this feeds, and the read-only contract that must survive it.
  • Media — the wider media stack and the acquisition port map.
  • Acquisition Egress Confinement — the kill switch, the proxy directions, and why a dead exit node stalls rather than leaks.
  • ereshkigal storage & health — the LVM layout, the kurVolumes landmines, and the qBittorrent CIFS migration.
  • Ollama — the failover proxy and the model-parity contract §4 depends on.
  • ereshkigal — the host.
  • myon — the exit node all 990 GB traverses.