Compare commits

...
63 Commits
Author SHA1 Message Date
poprhythm 54cda9027a falco: tune gitea-web proxy noise, bound log growth
nginx-proxy's routine reverse-proxied traffic to gitea:3000 trips the
"Redirect stdout/stdin to network connection" rule every 5-15 min via
the same dup3 mechanic as the already-excluded sshd case - not a new
attack pattern, just proxied web traffic volume. Scoped to gitea's own
fixed internal port so a redirect on any other port from this
previously-compromised container still alerts.

Also caps falco's own json-file log at 50MB x5 files - it had grown
to 1.1GB unbounded (mostly 15s-interval metrics snapshots), which was
making `docker logs falco` unreliable for the exact triage step its
own alert text points admins to.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-26 01:16:48 +00:00
poprhythm 39fdc4276f Add subwave skill for SUB/WAVE admin-API interactions
Wraps show/persona/schedule/trigger calls that were previously done by
hand with raw docker exec curl, and documents the gotchas hit tonight
(whole-object PUT/POST semantics, the frequency enum, the silent
default-persona trap, and the port-not-published-to-host setup).

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-19 23:40:00 +00:00
poprhythm 8a983fe307 docs: document the second nas_audiobooks flapping cause (oversized test file) 2026-09-18 03:26:33 +00:00
poprhythm c95fdef0e5 nfs-mount-heal: read a small chunk, not the whole file, in health checks
Root cause of nas_audiobooks still flapping after the automount fix: the
health check finds the first file in each share via `find -quit` and
`cat`s the whole thing within a 10s timeout. That's fine for immich's
13-byte marker and owncloud's 0-byte one, but audiobooks has no such
marker - find picked a real 2.9GB audiobook file, which can never fully
transfer in 10s regardless of mount health. Switched to `head -c 64k`,
which only needs to prove the file handle/NFS path is alive. Confirmed:
the mount was never actually unhealthy post the automount fix, this test
methodology was generating the "stale" verdicts (cat exit=124) itself.

Also captures and logs/alerts the actual find/head error text now instead
of a generic "stale/unreachable" guess, so any future real failure is
diagnosable without SSH archaeology.
2026-09-18 03:26:09 +00:00
poprhythm a0921dd387 system-config: back up nas_audiobooks mount fix and missing unit files
Adds the 4 permanent .mount units (family/books/owncloud/audiobooks) and
audiobookshelf-mount-ready.service to the repo backup, which weren't
tracked here yet, and updates the README to document the autofs->permanent
mount fix applied to nas_audiobooks today so a host rebuild doesn't
recreate the broken automount version.
2026-09-17 21:16:57 +00:00
poprhythm d397938c18 docs: audiobookshelf's nas_audiobooks mount migrated off autofs (fixed staleness)
Root cause of the recurring nas_audiobooks ESTALE flapping (18 remounts in
82min on 9/15, 13+ continuous through several hours on 9/17): the mount
sat behind mnt-nas_audiobooks.automount instead of a permanent .mount unit,
exactly matching this doc's own pre-existing "Docker bind mounts + automount
= stale handles" gotcha. Switched to systemd enable --now on the .mount
unit directly (matching nas_family/nas_books/nas_owncloud), removed the
automount unit. Confirmed stable after the change - self-heal script no
longer needed as anything but a backstop for this share.
2026-09-17 21:15:59 +00:00
poprhythm 2ce5a1c9a4 docs: fix nas_media docker volume opts after 2026-09-16 outage
Host reboot broke the nas_media Docker NFS volume for 6+ hours (plex,
qbittorrent x3, navidrome, sonarr, jellyfin all down): nfs-utils 2.6.4 no
longer accepts the ":/path" + addr= device split, and separately Docker's
local volume driver can't do nfsvers=4 (calls mount(2) directly, skipping
the mount.nfs helper's version negotiation). Volume recreated with an
explicit host:/path device and vers=3. Updating docs/comments to match
the corrected live config so the next person doesn't recreate the same
broken volume from the stale example.
2026-09-16 23:34:48 +00:00
poprhythm 31f09721cd falco: tune out host-reboot false positives from 2026-09-16 boot burst
Host rebooted 2026-09-16 17:47 UTC, recreating every container at once and
producing 70 alerts in a single burst - all traced to benign init-time
patterns that will recur on every future reboot: systemd-executor's PAM
reads during boot, firefly-iii/fidi's s6 healthcheck shell, and romm's own
loopback redis/app connection at container start.
2026-09-16 23:19:54 +00:00
poprhythm 435cb931b6 Add navidrome skill for Subsonic API playlist curation
Distilled from tonight's session building SUB/WAVE anchor-playlist
shows by hand: a CLI (search/artist/album/playlist get/create/replace/
add/remove) plus a playlist-audit command that flags accidental
full-album dumps, replacing the one-off curl/Python snippets used
throughout. SKILL.md documents the gotchas hit along the way
(case-sensitive artist matching, deluxe-reissue duplicate tracks,
createPlaylist's full-replace semantics) and this session's curation
conventions (15-20 tracks/artist, prefer playlistStrict).

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 23:20:02 +00:00
poprhythm b7bee3e02f Set ollama's server-level context default to 11264, fix compose drift
The prior fix dropped numCtx to 8192 to guarantee full GPU offload,
but real multi-turn djAgentPick conversations peak around 7.5k tokens
-- leaving almost no headroom before llama.cpp's context-shift drops
the front of the prompt (system instructions + tool defs, including
`done`). Result: "agent stopped without calling done" recurring every
few picks. 11264 is the largest context that still offloads all 29
layers on this GPU (tested empirically -- 12288 fell 15MB short and
dropped to 28/29), leaving ~3.7k tokens of margin over the observed
peak.

Also: OLLAMA_CONTEXT_LENGTH had drifted onto the running container via
an earlier manual `docker run` and was never in this compose file, so
a prior git-redeploy silently kept the manual value instead of the
committed one. Committing it here closes that drift.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 16:36:39 +00:00
poprhythm 9b26006597 Add portainer.sh fix-git-auth for stacks with lost GitConfig credentials
The ollama stack's GitConfig.Authentication.Password had gone empty
(same class of issue as the prior Gitea log-poisoning/token-rotation
work), causing redeploy to fail with "Unable to clone git repository
directory". This resupplies repositoryUsername/repositoryPassword
from GITEA_USER/GITEA_TOKEN on redeploy, scoped to a new subcommand
rather than changing default redeploy behavior for every stack.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 15:38:34 +00:00
poprhythm ad0bfb8146 Enable flash attention + quantized KV cache for ollama
Observed KV cache size for qwen2.5:7b at fixed 16384 context varying
224MB-896MB between model loads with flash attention off. The larger
figure pushes total memory needs just past the GPU's free VRAM, so
some loads only offload 25/29 layers instead of 29/29 -- causing
DJ-agent pick latency to jump from ~1s to multiple minutes. This GPU
(6GB) has very little slack for this model/context combination even
after freeing obico and stable-diffusion's VRAM reservations.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 15:30:36 +00:00
poprhythm cd127a23e0 Remove obico's GPU reservation to free VRAM for ollama
obico's own comment already notes its :cuda tag falls back to CPU
inference on this driver/GPU combo, but it was still reserving 826MB
of VRAM it never used productively. That squeeze was forcing ollama
to offload only 20/29 model layers to GPU, pushing the rest onto CPU
and causing severe latency (multi-minute LLM calls) that stalled
SUB/WAVE's track picking.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 13:27:37 +00:00
poprhythm b2e7fcf451 Pin navidrome to 0.64.0 instead of :latest
0.64.0 re-encoded every internal ID in the DB (a documented breaking
change), which watchtower auto-applied and silently broke every
SUB/WAVE show's playlistIds reference. Pinning stops watchtower from
jumping versions without a deliberate review of release notes.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-13 12:41:09 +00:00
poprhythm e4115dfda4 navidrome: disable auto-import of .m3u playlists found in the library
ND_AUTOIMPORTPLAYLISTS defaults to true and was importing every .m3u it
found scanning the music folder (one per album from ripping software) as
a playlist -- 2059 of them, 1605 completely empty, hundreds duplicated.
Cleaned up the existing junk via the Subsonic API; this stops it from
coming back on the next scan.
2026-09-13 00:35:54 +00:00
poprhythm 4653e91f61 subwave: resolve duplicates after library additions unlocked new matches
User added several previously-recommended albums/compilations to the
library (AC/DC Ballbreaker, Rancid ...And Out Come the Wolves, Garbage,
Foo Fighters, Goo Goo Dolls, Melissa Etheridge x2, Big Shiny Tunes, MTV
Buzz Bin Vol. 2, etc.) and rescanned Navidrome. Re-running the matcher
across all 12 shows resolved many previous misses, which surfaced 6 new
cross-show duplicates (a song now matching in two shows at once). Each
was resolved by keeping the track on whichever show has its authentic/
better chart rank, matching the same policy used earlier this session.

Match counts after re-run: 1994 shows 13/29, 10/24, 10/27, 14/26; 1995
shows 22/30, 20/30, 19/30, 10/17; 1996 shows 17/35, 15/28, 19/29, 13/30.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-12 23:54:07 +00:00
poprhythm ef77443e49 subwave: add 4 shows for 1994, fix a cross-show duplicate track
Adds Tunecaster Rock Top 30 for 1994-01-08, 1994-04-09, 1994-07-09, and
1994-10-15, checked against all existing shows' actual live Navidrome
playlist contents (not just the hand-compiled list of chart titles used so
far, which had already let one duplicate slip through -- see below) for
exact-track duplicates.

Also fixes a real duplicate: "Everclear - Santa Monica" was airing in both
the 1995-12-16 and 1996-01-13 shows, because the manual text-based dedup
check compared exact strings and missed that "Santa Monica" and "Santa
Monica (Watch The World Die)" resolve to the same library track (the
matcher's containment-boost logic correctly treats them as the same song;
the manual check didn't). Removed from 1995-12-16, kept on the thinner
1996-01-13 show.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-11 22:45:16 +00:00
poprhythm d493b60c8b subwave: default playlistExhaust=true for created shows, fixes repeats
A playlistStrict show's no-repeat window scales down to a fraction of the
playlist size and disables entirely below 15 tracks (recency.ts's
effectiveNoRepeatWindow) -- every historical-chart playlist this script
builds is well under that (7-30 tracks), so repeats were essentially
unthrottled. playlistExhaust switches to a full-rotation window instead.
Also applied this fix retroactively to all 8 existing shows.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-11 22:09:45 +00:00
poprhythm 7ec3dca180 subwave: add 4 more 1996 shows, add artist-score floor to fix false match
Adds Tunecaster Rock Top 30 for 1996-01-13, 1996-04-13, 1996-07-13, and
1996-12-14, cross-checked against all 4 existing 1995 shows and each other
for exact-track duplicates.

Also fixes a real false-positive: "John Mellencamp - Just Another Day" was
matching Jon Secada's unrelated song of the same generic title, because a
perfect title score could carry a middling-but-not-absurd artist-name
coincidence (0.56) over the acceptance threshold. Added a hard artist
similarity floor (0.65) independent of the combined score.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-11 21:56:22 +00:00
poprhythm 4a311301e2 subwave: fix matcher scoring for composite credits/subtitles, add 3 more 1995 shows
score_candidate compared library tags verbatim against chart artist/title, so a
reissue's composite artist credit (e.g. "Green Day • Billie Joe Armstrong, Mike
Dirnt, & Tre Cool") or a dropped subtitle ("Sour Times" vs "Sour Times (Nobody
Loves Me)") could push an otherwise-correct match just under the score
threshold. Added whole-word substring containment as a scoring boost for both
fields, which recovered several real misses.

Also adds three more 1995 Tunecaster Rock Top 30 weeks (Jan 14, Jun 10, Dec 16)
alongside the existing Oct 28 show, cross-checked against it for exact-track
duplicates (swapped for a different real single by the same artist, or dropped
where none existed in the library).

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-11 21:50:39 +00:00
poprhythm ba624f33a1 subwave: add recreate_broadcast.py to build historical-chart shows
Given a hand-transcribed {station, date, tracks} JSON file (chart data is
sourced manually in conversation, never scraped in code -- ARSA's robots.txt
disallows automated fetching of its survey pages), matches tracks against the
Navidrome library, builds/updates a Navidrome playlist from the matches, and
wires it into a SUB/WAVE show with playlistStrict so it plays only that chart.

Claude-Session: https://claude.ai/code/session_01L7Rwa6guD5wK8F8tWQwcJX
2026-09-11 12:46:09 +00:00
poprhythm 7531bd698b Add qbt-relink-episode.sh for single-file standard-episode torrents
Third shape variant: qbt-relink.sh handles season packs (N files
matching N files), qbt-relink-daily.sh handles single-file
date-based episodes (Jeopardy). This handles single-file torrents
carrying one standard SxxEyy episode (e.g. Lower Decks, downloaded
one file per torrent rather than as season packs) - matches by
parsed season/episode number against the single file in that
Season NN folder carrying the same numbers. Same safety model
throughout: one blocking pass, pre/post filesystem size check
independent of qBittorrent's self-reported state, idempotent.

Verified against a live torrent - clean single-pass relink.

Claude-Session: https://claude.ai/code/session_01KjrQwEec4vzukbnHiFpFmk
2026-09-10 02:39:17 +00:00
poprhythm 7c65401b2c Add qbt-relink-daily-batch.sh to drive qbt-relink-daily.sh over
many single-file torrents

Runs a bounded-concurrency job pool (default 4) across every torrent
matching a name substring, logging each to its own file. The
orchestration here carries no data-safety risk beyond inefficiency
- each qbt-relink-daily.sh invocation is independently safe and
idempotent, so a driver bug can misreport or waste time but can't
corrupt data. Failures are logged and skipped, never auto-retried
within a run - re-running the whole batch picks up anything
incomplete since both scripts are idempotent.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 12:53:25 +00:00
poprhythm 86b1610e05 Add qbt-relink-daily.sh for single-file (daily show) torrents
qbt-relink.sh assumes torrent file count matches the destination
folder's file count 1:1 - true for season packs, false for a daily
show like Jeopardy where each torrent holds one episode but the
destination Season <year> folder holds every episode that aired
that year. This matches by date parsed from the torrent's own
filename against the single dated file in that season folder,
using the same safety model (single blocking pass, pre/post
filesystem size check, idempotent, no repeated manual triggering).

Verified against a live torrent - clean single-pass relink,
post-check confirmed the file intact.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 12:52:50 +00:00
poprhythm ae8a929a92 Fix second Argument-list-too-long spot in cmd_import
The command_id extraction passed the full command-submission
response (which echoes back the whole files array) as a python argv
arg - same ARG_MAX issue as the api_post fix, one step later. Now
piped via stdin. The actual command submission already worked with
the first fix; this only affected reading back the response.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 12:15:27 +00:00
poprhythm 5671b67fa5 Fix "Argument list too long" in sonarr.sh for large imports
api_post/api_put passed the JSON payload directly as a curl -d
command-line argument, which hit the OS ARG_MAX limit importing
Jeopardy's 1269-file library (a large date-based show is exactly
the case where a batch import payload gets big). Now written to a
temp file and passed via curl's -d @file instead.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 12:14:19 +00:00
poprhythm a54f11ccbc Update sonarr skill doc: qbt-relink.sh re-enabled
Reflects the rewritten script's blocking single-pass model and the
hard rule against re-triggering stop/setLocation/recheck against the
same hash more than once. Marks Incident 3 as fixed and verified.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 03:16:58 +00:00
poprhythm 6e93ca6c39 Rewrite qbt-relink.sh with a real safety model, re-enable it
Root cause of the data-loss incident: repeated manual re-triggering
(stop/setLocation/recheck called multiple times across separate
debugging invocations) raced against qBittorrent's own automatic
incomplete-file management. The 7 clean successes earlier all
completed in one uninterrupted pass; the 2 that failed were the ones
manually re-triggered while investigating.

Redesign: the whole operation is now one blocking pass (stop ->
relocate -> rename -> recheck -> poll to completion -> verify ->
report) with a hard rule never to call recheck/stop/setLocation
against the same hash a second time while a previous run might
still be settling. Adds:
- A pre-flight filesystem manifest (name+size) of the destination,
  and a post-recheck comparison against it - the real ground-truth
  safety net, independent of trusting qBittorrent's self-reported
  state.
- Idempotency: skips already-correctly-relinked torrents rather
  than re-touching them. Found and fixed a real gap here during
  testing - the first version trusted qBittorrent's per-file
  "progress" alone, which can be stale (cached from before a move)
  and produced a false "already good" on a torrent whose recheck
  had never actually run at the new location. Now also requires the
  tracked filenames to match the destination manifest.
- setLocation/setDownloadPath verification now polls briefly instead
  of checking once immediately - both are asynchronous and a single
  immediate check can read stale data (this exact bug false-failed
  a real run during testing).
- Fixed manifest generation to use printf instead of `stat -c`'s own
  \t escape handling, which silently emitted a literal backslash-t
  instead of a real tab and broke `cut -f1` pairing.

Verified end-to-end against a real torrent (Babylon 5 S04, 69GB):
clean single-pass recheck, post-check confirmed all 22 files intact.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 03:16:05 +00:00
poprhythm 49e47a32bf Document data-loss incident, pause qbt-relink.sh
Babylon 5 S03/S04 (44 episodes) were destroyed during a relink
attempt despite every safety check the script performs (HTTP status,
save_path, content_path) passing. qBittorrent's own automatic
incomplete-file management took further unrequested action after
the script's calls succeeded - moving/truncating files at a
location it still considered incomplete - which none of the
script's checks could see coming since they only verify immediately
after its own calls.

No backup existed. Files could not be found anywhere on /data after
a full search. Marking qbt-relink.sh unsafe until the interaction
with qBittorrent's temp_path_enabled behavior is understood well
enough to prevent this.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 10:13:07 +00:00
poprhythm 027108b492 Fix stale download_path override and unsafe alphabetical pairing
in qbt-relink.sh

Second live incident in the same batch: setLocation genuinely
succeeded and save_path updated correctly, but content_path (what
recheck actually reads) stayed pointed at qBittorrent's incomplete
staging path via a leftover per-torrent download_path override from
an earlier failed attempt. setLocation doesn't clear that override.
Fixed by also calling torrents/setDownloadPath (note: takes "id",
not "hashes") and verifying content_path directly before proceeding.

Also replaced alphabetical-sort file pairing with SxxEyy-parsed
matching, since sort order silently breaks on non-zero-padded
episode numbers (E9 sorts after E10) - a real risk across the
~320 remaining folders with inconsistent naming conventions.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 03:17:12 +00:00
poprhythm f2ac2e5c53 Add sonarr.sh monitor command for shows still actively airing
Migrated shows default to unmonitored/no-search since they're
back-catalog imports. For a show still marked "continuing" on TVDB
(e.g. 3 Body Problem), this flips it (and all its seasons) to
monitored so new episodes flow through the automated tv-sonarr
pipeline instead of needing a manual scan/import each time one
drops. --search optionally triggers an immediate missing-episode
search.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 03:07:51 +00:00
poprhythm fddd462ff3 Harden qbt-relink.sh after a live incident: unchecked setLocation
let a torrent start re-downloading

Batch-relinking 9 torrents, one setLocation call silently failed
(status ignored) while the script declared success and moved on.
Its recheck then ran against the original (now-empty) path, found
0% match, and qBittorrent started re-downloading the whole torrent
from scratch into its incomplete-files staging area. No lasting
harm (separate path from the real hardlinked copy, cleaned up), but
caught only by watching qBittorrent directly, not by anything the
script reported.

Fixes: every mutating call now goes through an api_call() helper
that checks the HTTP status and aborts on failure; the torrent is
stopped before any location/rename calls (qBittorrent 5.x renamed
pause/resume to stop/start) and left stopped after recheck rather
than auto-resuming, so a bad relink can never turn into an active
download. setLocation's effect is also verified via a follow-up
GET before proceeding to renameFile.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 03:01:22 +00:00
poprhythm 2b65279673 Add qbt-relink.sh to fix qBittorrent after a Sonarr import
Sonarr's hardlink-import removes files from their original
torrent-named folder, but qBittorrent's own records still point
there - the next recheck or peer request flips those downloads to
"missing files". qbt-relink.sh re-points a torrent's location and
per-file names at the Sonarr-organized destination and triggers a
recheck, so seeding continues against the same underlying data
(same inode) instead of erroring out or needing a re-download.

Documented as a required step in the sonarr skill's per-show
migration workflow.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 02:55:14 +00:00
poprhythm 34a45cba76 Add sonarr.sh + skill for TV library manual-import migration
claude-homelab's Sonarr skill only covers search/add/remove, not the
manual-import workflow the Jellyfin migration depends on. Wraps
lookup/add/scan/import into a script matching this repo's
portainer.sh conventions, replacing repetitive raw curl calls.

Also documents a naming-token gotcha hit while migrating the first
two pilot shows: Sonarr's series folder format needs the combined
{Series TitleYear} token, not {Series Title} ({Year}) - the latter
silently drops the year.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 02:38:48 +00:00
poprhythm 718f25ca6b Add Sonarr for Jellyfin-compliant TV show organization
Coordinates with qBittorrent to auto-rename/organize new TV downloads
into the Show Name (Year)/Season NN/... layout Jellyfin needs, and
provides the same import engine for migrating the existing library.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 02:14:02 +00:00
poprhythm 4d0bcc9042 Connect jellyfin to npm-network for internal reverse-proxy access
Enables an nginx-proxy-manager proxy host (jellyfin:8096) so it can be
reached by hostname on the LAN instead of IP:port. No public
VIRTUAL_HOST/LETSENCRYPT_HOST since this stays internal-only for now.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-06 23:47:41 +00:00
poprhythm bed53e6e06 Add jellyfin stack, sharing plex's media library
Roku-accessible on the LAN via direct IP:8096; nginx proxy setup held
off for now. Reuses the existing nas_media volume and GPU passthrough
pattern from plex/docker-compose.yaml.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-06 23:41:35 +00:00
poprhythm f82a76c3bb tronbyt: disable open registration now that admin account exists
Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-05 01:53:57 +00:00
poprhythm 65d80c5c0e tronbyt: move host port to 8001 (8000 conflicts with portainer)
Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-05 01:44:26 +00:00
poprhythm 7e06ce0f93 Add tronbyt server stack
Self-hosted server for managing Tronbyt/Tidbyt pixel displays without
relying on Tidbyt's cloud, using data dir at /srv/tronbyt-data.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-05 01:42:00 +00:00
poprhythm 50a5a48ed4 Fix false-positive stale-mount detection in nfs-mount-heal.sh
find -maxdepth 3 couldn't reach immich's thumbs/<uuid>/XX/YY/file (4
levels deep), so it exhausted the 256x256 hash-bucket fan-out with no
match and timed out on every run -- 3 spurious remounts/restarts of
immich_server within 15 minutes of deploy, all falsely alerted as
staleness. Drop -maxdepth entirely; -quit already stops at the first
match via depth-first search, so it's fast regardless of tree depth.
2026-08-25 22:06:43 +00:00
poprhythm 42a31b6a44 Add NFS self-heal automation for immich/calibre/ocis, backed up in git
Stale NFS handles inside containers (ESTALE/errno -116) can persist even
after the host-side mount looks healthy, requiring a manual remount +
container restart. Adds systemd mount-ready hooks (BindsTo) to restart
immich_server/calibre/ocis when their NFS mount unit restarts, plus a
5-minute health-check timer that does a real nested read and force-remounts
on staleness. Host files backed up under system-config/nfs-self-heal/ so
they can be reinstalled after a host rebuild.
2026-08-25 21:56:48 +00:00
poprhythm d302060e4b falco: exclude loopback destinations from miner-pool-port rule
Confirmed 5 false positives over 2026-08-20 to 2026-08-25, all connecting
to ::1:8888 at ~21:3x daily - never a real mining-pool destination since
that's loopback, and every attempt failed instantly (nothing listens on
8888). Couldn't identify the source process (falco loses metadata for it
before it can be captured), but a real miner pool can never be localhost,
so excluding loopback fixes the false-positive class without weakening
the rule for actual remote pool connections.
2026-08-25 21:44:03 +00:00
poprhythm ab73eb2ebb minecraft: set Survival SMP seed -1102400853764765851 2026-08-23 16:25:20 +00:00
poprhythm 12823249aa minecraft: add Survival SMP, comment out Create SMP 2026-08-23 14:33:59 +00:00
poprhythm 10cc1c823b netdata: silence 10min_cpu_usage alarm now that Falco covers miner detection
This alarm was originally the crude proxy for catching cryptominers, but
Falco now detects that directly via process/network behavior. It was
firing several times a day from Ollama's legitimate sustained CPU use
(subwave-controller driving chat completions), with no way to tell that
apart from a real problem using aggregate CPU % alone. Routed to netdata's
"silent" role so it stays visible on the dashboard but stops paging
Telegram/Cloud email.
2026-08-21 01:28:33 +00:00
poprhythm 7c0340519c falco: tune out calibre sudoers-sed and lsyncd log-truncation false positives
Investigated recent Telegram alerts - no intrusion, both new hits trace to
benign one-offs: calibre's linuxserver.io s6-init NOPASSWD setup (same
pattern already whitelisted for obsidian) and lsyncd truncating its own
status log (not evidence-clearing).
2026-08-20 22:18:12 +00:00
poprhythm 8daf4a12d4 minecraft: update Create SMP modpack to 2026-08-20 zip 2026-08-20 22:01:52 +00:00
poprhythm 23f8b903da minecraft: switch back to Create SMP, comment out 3rd Life
New seed -1307549829135522678 (old world data cleared for fresh generation).
Port 55567 stays the same.
2026-08-20 21:44:09 +00:00
poprhythm 53e327c4be glances: attach to npm-network for reverse proxy access 2026-08-17 01:40:15 +00:00
poprhythm 17030f7647 technitium: add container-host DNS instance for cluster mode with dns02
Deploys a second Technitium instance on the container host (192.168.1.67)
to pair with the existing Pi-based instance at dns02 (192.168.1.45) via
native Technitium v14+ clustering for DNS redundancy.
2026-08-17 01:33:49 +00:00
poprhythm 5d2e43c74e dashy: link falco alerts to netdata's Alerts tab instead of homepage 2026-08-16 16:43:38 +00:00
poprhythm 390916cf31 dashy: merge link and description into one line with short date+time 2026-08-16 15:52:28 +00:00
poprhythm 1ec0179de7 chore: revert test comment from git-hook-tamper verification 2026-08-16 15:41:33 +00:00
poprhythm 9ac8960a27 test: verify git-hook-tamper fix (will squash/revert) 2026-08-16 15:41:20 +00:00
poprhythm a44f35e758 falco: fix git-hook-tamper false-positiving on every gitea push
The rule checked proc.name against gitea's managed hook names, but git's
hook dispatcher always execs these as `bash ./hooks/<hookname>.d/gitea` -
proc.name is "bash" (the interpreter), never the hook name. That check
could never match, so this fired CRITICAL 3x (pre-receive/update/
post-receive) on every single push since it was added - including two
notifications the user got moments ago from this repo's own commits.

Fixed to check the actual invoked script path in proc.cmdline instead.
This commit's own push is the live verification.
2026-08-16 15:41:05 +00:00
poprhythm 4420ed3c8b dashy: show local time in Falco alerts widget, cap to 5 entries 2026-08-16 15:35:58 +00:00
poprhythm 33868ee9bb falco: tune out gitea SSH and firefly-iii wait-for-it.sh stdio redirects
"Redirect STDOUT/STDIN to Network Connection in Container" (a reverse-shell
detector) was firing legitimately: gitea's sshd/sshd-session dup2 the
accepted SSH socket onto stdio for every session (4x per connection), and
firefly-iii's wait-for-it.sh does the same TCP-readiness-check dance while
waiting for postgres. Both confirmed recurring via netdata's alert history,
not one-off. Scoped exceptions added to each's specific binary/cmdline,
not the whole container/image.

Verified: a gitea SSH login no longer alerts, while a real dup2-based
redirect (bash's /dev/tcp exec pattern) from an unrelated container still
fires - the exception is narrow, not a blanket disable.

"Run shell untrusted" was also flagged as noisy, but investigation showed
it fired exactly once, during my own rule-testing window, and never
before or since - left alone rather than building a permanent exception
for a self-caused test artifact.
2026-08-16 15:25:04 +00:00
poprhythm d87c4e76ef dashy: add recent Falco alerts widget
Cron script (every 5m) pulls falco_rule_match raise transitions from
netdata's existing alert_transitions API - the same data already driving
the Telegram pipeline - and writes them as a Dashy custom-list JSON file.
No new containers/Redis/Falcosidekick needed for a quick-glance security
event feed on the dashboard.

conf.yml and the crontab entry live outside the repo (per existing
pattern) - conf.yml got a new custom-list widget block in the System
Widgets section, and crontab got a matching */5 entry alongside the
other host-cron jobs.
2026-08-16 15:20:09 +00:00
poprhythm 543d374179 firefly-iii: expose FIDI on 8383 for the SimpleFIN setup UI
Not proxied through NPM - it's a one-time interactive setup plus a
periodic cron target, doesn't need a permanent public hostname.
2026-08-16 14:44:22 +00:00
poprhythm 20e93cea4c falco: scope cloud-metadata-probe past netdata's own auto-detection curl
Found during live testing: netdata's cloud-provider auto-detection runs
curl --fail -s -m1 --noproxy * http://169.254.169.254 on every
startup/reconnect to check whether the host is in AWS/GCP/Azure - standard
monitoring-agent behavior, not a bug. Without this exception the rule
would have paged every time netdata restarts. Scoped to netdata's own
curl specifically so any other connection from that container still
alerts.
2026-08-16 14:40:39 +00:00
poprhythm 2ed0565486 add deploy-stack skill; falco: fix hook-tamper condition, add three more post-incident rules
git-hook-tamper.yaml's condition used proc.exepath, which resolves to the
script interpreter's path (e.g. /bin/busybox) for shebang scripts, not the
script's own path - switched to proc.cmdline, which retains the originally
invoked path. Confirmed via live-testing both ways.

ssh-persistence/cloud-metadata-probe/db-spawned-process round out the
post-incident hardening pass with a few more incubating-ruleset adaptations.
2026-08-16 14:33:56 +00:00
poprhythm 9bf72f8e19 falco: add rules for git hook tampering and unexpected pack-service children
Written after the 2026-08-10/11 gitea internal-API log-poisoning attack
that planted a malicious uploadpack.packObjectsHook backdoor. Catches
both the planting (unexpected exec from a hooks/ path) and the firing
(git-upload-pack/git-receive-pack spawning anything but its own
pack-objects binary), independent of how the hook config got written.
2026-08-16 14:24:29 +00:00
60 changed files with 5307 additions and 35 deletions
+79
View File
@@ -0,0 +1,79 @@
---
name: deploy-stack
description: Deploy a docker-infrastructure change - push to Gitea, then redeploy the stack via Portainer. Use whenever a service's docker-compose.yaml (or other tracked file) changed and needs to go live.
---
# Deploy a stack (Gitea → Portainer)
This repo's services go live via GitOps: commit → push to Gitea (self-hosted, SSH
remote) → tell Portainer to pull + redeploy. Always use `./portainer.sh` for the
Portainer side; never raw `curl` unless `portainer.sh` can't do it.
## Steps
1. **Stage and commit** only the files that changed for this task (never `git add -A`).
Imperative mood, first line < 72 chars, explain *why* not *what*.
2. **Sync with remote before pushing.** `git push origin main` fails non-fast-forward
if anyone (or the Gitea web UI) committed since your last pull. Prefer:
```
git pull --no-edit origin main # fetch + merge in one step
git push origin main
```
The remote is SSH (`git@gitea.kolpacksoftware.com:...`). `GITEA_TOKEN` in
`.credentials` is NOT valid for HTTPS push (403) - don't try HTTPS for push.
3. **If push/fetch fails with a low-level git protocol error** (`bad pack header`,
`unable to fork git-pack-objects`, `cannot exec '.../hooks/...'`) - this is NOT
a normal merge conflict, it means Gitea's git service itself is broken
(corrupted repo, poisoned global gitconfig, etc). Do not just retry blindly.
Stop and investigate server-side (`docker logs gitea`, check
`/data/gitea/home/.gitconfig` inside the container) before continuing - this
exact failure mode was an active RCE backdoor once (see
`gitea-log-poisoning-attack-2026-08.md` memory). HTTPS fetch with `GITEA_TOKEN`
can help bisect whether it's SSH-specific or instance-wide:
```
source .credentials
git fetch "https://${GITEA_USER}:${GITEA_TOKEN}@gitea.kolpacksoftware.com/homelab/docker-infrastructure.git" main
```
4. **Find the stack name/ID** if you don't already know it:
```
source .credentials && ./portainer.sh list
```
Stack names in Portainer usually match the service directory name, but not
always (verify with `list`, don't assume).
5. **Redeploy** (pulls latest git commit + recreates containers):
```
source .credentials && ./portainer.sh redeploy <stack-name>
```
This only works for stacks that are git-linked in Portainer. If it's not
git-linked, changes to `.env` values must go through
`./portainer.sh set-env <stack-name> KEY=VALUE` instead - a `.env` file at
the repo path is gitignored and NOT read by git-linked Portainer deploys.
6. **Verify**: `docker ps --filter name=<container>` for status, `docker logs
<container> --tail 50` to confirm it actually came up clean, not just "Up".
## Gotchas
- **`.env` files are gitignored and invisible to git-linked stacks.** Portainer
reads env vars it has stored for the stack (set via UI or `portainer.sh
set-env`), not the local `.env` file. Check current values first with
`./portainer.sh get-env <stack-name>` before adding new ones, and set any new
var explicitly - editing the local `.env` alone does nothing for a deployed
git-linked stack.
- **Not every service is a Portainer stack.** Some (e.g. `falco`) are run via
plain `docker run`/manual `docker compose`, bind-mounting config directly from
this repo checkout. For those, `portainer.sh redeploy` will fail with "stack
not found" - check `docker inspect <container> --format '{{json .Mounts}}'`
to see if it's bind-mounted from this repo (if so, a `git push` alone is
enough to update the *source* files; the container itself needs a manual
`docker restart` or recreate to pick up new volume mounts).
- **No `docker compose` CLI on this host.** `docker compose up -d` fails
outright - always go through Portainer, or `docker run`/`docker restart`
directly for manually-managed services.
- Portainer's `GET /api/stacks/<id>` can return Unauthorized for some stacks -
`portainer.sh` already works around this by listing + filtering; don't call
the raw API directly.
+84
View File
@@ -0,0 +1,84 @@
---
name: navidrome
description: Query and edit the Navidrome music library/playlists (search artists/albums, inspect or build playlists, audit for accidental full-album dumps). Use for any SUB/WAVE anchor-playlist curation work or general Navidrome library lookups.
---
# Navidrome (Subsonic API)
A CLI wrapper (`navidrome_api.py`, in this skill's directory) around Navidrome's
Subsonic API, distilled from a long session of building SUB/WAVE anchor-playlist
shows by hand. Use it instead of writing one-off `curl`/Python snippets — every
gotcha below was hit at least once doing it the ad-hoc way.
## Setup
Auth/URL come from `/srv/subwave/state/setup-config.json` (world-readable),
already pointed at `navidrome:4533` — the script rewrites that host to
`localhost` for you. No flags needed for auth.
Run it as: `python3 .claude/skills/navidrome/navidrome_api.py <command> ...`
## Commands
```
search <query> [--artists N] [--albums N] [--songs N] # raw multi-type search
artist <name> # albums + track counts for one artist
album <artist> <album> # track ids for one album
playlists # list all playlists (id, count, name)
playlist get <id> # dump entries
playlist create <name> --ids id1,id2,... # new playlist
playlist replace <id> --ids id1,id2,... # FULL REPLACE of contents
playlist add <id> --ids id1,id2,... # append in place
playlist remove <id> --ids id1,id2,... # remove by id, in place
playlist audit <id> [--min-size N] # full-album-dump check (see below)
```
## Gotchas learned the hard way
- **Artist name casing is inconsistent and case-sensitive matching silently
drops real hits.** `eels` is lowercase, `CAKE` is uppercase, most others are
title-case. A naive `artist["name"] == "Eels"` filter returns "not found" even
though the artist is right there. `artist`/`album` in this script already do
case-insensitive exact matching — don't re-derive this bug with a fresh `curl`.
- **An artist with 0 albums from `getArtist` isn't necessarily absent** — it
may only have loose tracks scattered across compilations (soundtrack/box-set
albums credited to "Various Artists"). Fall back to `search3` with a high
`songCount` and filter by exact artist name (the `artist` command does this
automatically when it finds zero grouped albums).
- **Reissues/deluxe editions duplicate the base album's tracks under a
different album name** (`Doolittle` vs `Doolittle 25`, `Bricks Are Heavy` vs
a live/remix bonus disc, etc). When curating, pick tracks from ONE edition —
check `album` output for suspiciously large counts before assuming it's all
distinct songs.
- **`createPlaylist` with `playlistId` set REPLACES the entire contents** —
it is not additive. Use `playlist replace` only when you intend to overwrite
everything (e.g., rebuilding after a curation pass). Use `playlist add` /
`playlist remove` for incremental edits to an existing playlist.
- **No native "remove by id" in the Subsonic API** — `playlist remove` here
works by reading the current entries, filtering out the unwanted ids in
Python, then doing a full `replace`. This is safe (order-preserving for
everything you keep) but means a remove is really a replace under the hood.
- **`playlist audit`'s "FULL ALBUM" flag is a signal, not a verdict.** A
genuinely short album (say, 10 tracks) will always show `10/10` with nothing
left to trim, and an artist you were explicitly told to go "heavy" on is
fine full. Use it to know where to *look*, then use judgment (or ask) before
trimming.
## Curation conventions established this session
- Default to **~15–20 tracks per artist** for an anchor playlist, not a full
discography — unless the user explicitly asks for heavier coverage of a
specific artist ("heavy Nirvana", "triple that, focusing on XTC").
- Prefer **`playlistStrict: true`** on SUB/WAVE shows with a pinned anchor
playlist. Soft-anchor (`playlistStrict: false`) was tried and rolled back
station-wide after live testing showed the playlist rarely won picks over
the genre/mood fallback pool, and the fallback's unfiltered "explore" source
could drift the show off-genre (see `subwave/bug-report-soft-anchor-drift.md`).
- Cross-show duplicate tracks are fine for these anchor-genre shows (the
no-duplicate rule only applies to the historical radio-chart recreation
shows) — don't spend time deduplicating against other playlists unless asked.
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Navidrome Subsonic-API CLI. Auth/config lifted from /srv/subwave/state/setup-config.json.
Usage:
navidrome_api.py search <query> [--artists N] [--albums N] [--songs N]
navidrome_api.py artist <name> # case-insensitive; lists albums + track counts
navidrome_api.py album <artist> <album> # lists tracks with ids for one album
navidrome_api.py playlists # list all playlists (name, id, song count)
navidrome_api.py playlist get <id> # dump entries (artist | album | title | id)
navidrome_api.py playlist create <name> --ids id1,id2,...
navidrome_api.py playlist replace <id> --ids id1,id2,... # createPlaylist w/ playlistId: FULL REPLACE
navidrome_api.py playlist add <id> --ids id1,id2,... # updatePlaylist songIdToAdd: in-place append
navidrome_api.py playlist remove <id> --ids id1,id2,... # updatePlaylist songIdToRemove
navidrome_api.py playlist audit <id> # per-artist/per-album breakdown + full-album flags
"""
import argparse
import hashlib
import json
import secrets
import sys
import urllib.parse
import urllib.request
from collections import defaultdict
SETUP_CONFIG = "/srv/subwave/state/setup-config.json"
def load_config():
cfg = json.load(open(SETUP_CONFIG))["navidrome"]
return {
"url": cfg["url"].replace("navidrome:", "localhost:"),
"user": cfg["user"],
"pass": cfg["pass"],
}
def call(endpoint, params=None, doseq=False):
cfg = load_config()
salt = secrets.token_hex(6)
token = hashlib.md5((cfg["pass"] + salt).encode()).hexdigest()
p = {"u": cfg["user"], "t": token, "s": salt, "v": "1.16.1", "c": "navidrome-skill", "f": "json"}
if params:
p.update(params)
q = urllib.parse.urlencode(p, doseq=doseq)
with urllib.request.urlopen(f"{cfg['url']}/rest/{endpoint}?{q}") as r:
d = json.load(r)["subsonic-response"]
if d.get("status") != "ok":
err = d.get("error", {})
raise RuntimeError(f"{endpoint} failed: {err.get('code')} {err.get('message')}")
return d
def find_artist_exact(name):
"""Case-insensitive exact-name match. Navidrome artist tags are inconsistently
cased ('eels' lowercase, 'CAKE' uppercase) - a naive `== name` filter silently
drops real hits. This is the #1 false-negative bug hit repeatedly this session."""
resp = call("search3", {"query": name, "artistCount": 10, "songCount": 0, "albumCount": 0})
for a in resp.get("searchResult3", {}).get("artist", []):
if a["name"].lower() == name.lower():
return a
return None
def cmd_search(args):
resp = call("search3", {"query": args.query, "artistCount": args.artists,
"albumCount": args.albums, "songCount": args.songs})
r = resp.get("searchResult3", {})
for a in r.get("artist", []):
print(f"artist | {a['name']} [{a['id']}]")
for al in r.get("album", []):
print(f"album | {al['artist']} - {al['name']} ({al.get('songCount')} tracks) [{al['id']}]")
for s in r.get("song", []):
print(f"song | {s['artist']} - {s['title']} ({s.get('album')}) [{s['id']}]")
def cmd_artist(args):
a = find_artist_exact(args.name)
if not a:
print(f"No exact match for '{args.name}'. Loose/compilation tracks may still exist -- "
f"try: navidrome_api.py search '{args.name}' --songs 20")
return
r = call("getArtist", {"id": a["id"]})
albums = r.get("artist", {}).get("album", [])
if not albums:
print(f"{a['name']}: artist entry exists but 0 grouped albums -- checking loose tracks...")
resp = call("search3", {"query": args.name, "artistCount": 0, "songCount": 30, "albumCount": 0})
for s in resp.get("searchResult3", {}).get("song", []):
if s["artist"].lower() == args.name.lower():
print(f" {s['title']} ({s.get('album')}) [{s['id']}]")
return
total = sum(al.get("songCount", 0) for al in albums)
print(f"{a['name']}: {len(albums)} albums, {total} tracks")
for al in albums:
print(f" {al['name']} ({al.get('year')}) - {al.get('songCount')} tracks [{al['id']}]")
def cmd_album(args):
a = find_artist_exact(args.artist)
if not a:
print(f"No exact artist match for '{args.artist}'")
return
r = call("getArtist", {"id": a["id"]})
for al in r.get("artist", {}).get("album", []):
if al["name"].lower() == args.album.lower():
aresp = call("getAlbum", {"id": al["id"]})
for s in aresp.get("album", {}).get("song", []):
print(f" {s.get('track', '?')}. {s['title']} [{s['id']}]")
return
print(f"No album '{args.album}' found for {a['name']}")
def cmd_playlists(args):
resp = call("getPlaylists")
for p in resp.get("playlists", {}).get("playlist", []):
print(f"{p['id']} | {p.get('songCount', '?'):>4} tracks | {p['name']}")
def cmd_playlist_get(args):
resp = call("getPlaylist", {"id": args.id})
for e in resp["playlist"]["entry"]:
print(f"{e['artist']} | {e['album']} | {e['title']} [{e['id']}]")
def cmd_playlist_create(args):
ids = args.ids.split(",")
resp = call("createPlaylist", {"name": args.name, "songId": ids}, doseq=True)
pl = resp.get("playlist", {})
print(f"created '{args.name}': {pl.get('id')} ({pl.get('songCount')} tracks)")
def cmd_playlist_replace(args):
ids = args.ids.split(",")
resp = call("createPlaylist", {"playlistId": args.id, "songId": ids}, doseq=True)
pl = resp.get("playlist", {})
print(f"replaced {args.id}: now {pl.get('songCount')} tracks")
def cmd_playlist_add(args):
ids = args.ids.split(",")
call("updatePlaylist", {"playlistId": args.id, "songIdToAdd": ids}, doseq=True)
resp = call("getPlaylist", {"id": args.id})
print(f"added {len(ids)}; {args.id} now has {len(resp['playlist']['entry'])} tracks")
def cmd_playlist_remove(args):
remove_ids = set(args.ids.split(","))
resp = call("getPlaylist", {"id": args.id})
entries = resp["playlist"]["entry"]
remaining = [e["id"] for e in entries if e["id"] not in remove_ids]
resp2 = call("createPlaylist", {"playlistId": args.id, "songId": remaining}, doseq=True)
print(f"removed {len(entries) - len(remaining)}; {args.id} now has "
f"{resp2.get('playlist', {}).get('songCount')} tracks")
def cmd_playlist_audit(args):
"""The recurring 'did we dump a full album in here' check from tonight's session:
group by (artist, album), then compare each group's count against that album's
real total songCount. An exact match is a strong full-catalog signal worth a
second look -- but treat it as a flag, not an automatic verdict: a genuinely
short album (e.g. a 10-track record) will always show 10/10 with no trimming
possible, and an artist you were explicitly told to go 'heavy' on is fine full."""
resp = call("getPlaylist", {"id": args.id})
entries = resp["playlist"]["entry"]
print(f"total tracks: {len(entries)}")
by_album = defaultdict(list)
for e in entries:
by_album[(e["artist"], e["album"])].append(e)
for (artist, album), tracks in sorted(by_album.items(), key=lambda x: -len(x[1])):
if len(tracks) < args.min_size:
continue
real_total = None
try:
aresp = call("search3", {"query": album, "artistCount": 0, "songCount": 0, "albumCount": 10})
for al in aresp.get("searchResult3", {}).get("album", []):
if al["artist"] == artist and al["name"] == album:
real_total = al.get("songCount")
break
except Exception:
pass
flag = ""
if real_total is not None and real_total == len(tracks):
flag = " <-- FULL ALBUM (playlist count == real album length)"
print(f" {artist} - {album}: {len(tracks)}"
f"{f'/{real_total}' if real_total is not None else ''} tracks{flag}")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("search")
p.add_argument("query")
p.add_argument("--artists", type=int, default=5)
p.add_argument("--albums", type=int, default=5)
p.add_argument("--songs", type=int, default=5)
p.set_defaults(func=cmd_search)
p = sub.add_parser("artist")
p.add_argument("name")
p.set_defaults(func=cmd_artist)
p = sub.add_parser("album")
p.add_argument("artist")
p.add_argument("album")
p.set_defaults(func=cmd_album)
p = sub.add_parser("playlists")
p.set_defaults(func=cmd_playlists)
pl = sub.add_parser("playlist")
pl_sub = pl.add_subparsers(dest="subcmd", required=True)
p = pl_sub.add_parser("get")
p.add_argument("id")
p.set_defaults(func=cmd_playlist_get)
p = pl_sub.add_parser("create")
p.add_argument("name")
p.add_argument("--ids", required=True, help="comma-separated song ids")
p.set_defaults(func=cmd_playlist_create)
p = pl_sub.add_parser("replace")
p.add_argument("id")
p.add_argument("--ids", required=True, help="comma-separated song ids; FULL REPLACE of contents")
p.set_defaults(func=cmd_playlist_replace)
p = pl_sub.add_parser("add")
p.add_argument("id")
p.add_argument("--ids", required=True, help="comma-separated song ids to append")
p.set_defaults(func=cmd_playlist_add)
p = pl_sub.add_parser("remove")
p.add_argument("id")
p.add_argument("--ids", required=True, help="comma-separated song ids to remove")
p.set_defaults(func=cmd_playlist_remove)
p = pl_sub.add_parser("audit")
p.add_argument("id")
p.add_argument("--min-size", type=int, default=8, help="only show (artist,album) groups >= this size")
p.set_defaults(func=cmd_playlist_audit)
args = parser.parse_args()
try:
args.func(args)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+309
View File
@@ -0,0 +1,309 @@
---
name: sonarr
description: Use Sonarr to add TV shows and manually import existing raw-named folders into the Show Name (Year)/Season NN/... layout Jellyfin and Plex expect. Use when migrating the TV library, importing a newly-downloaded show, or otherwise interacting with this repo's Sonarr instance.
---
# Sonarr library migration/import
**`qbt-relink.sh` was paused after a real data-loss incident (2026-09-08,
Babylon 5 S03/S04, 44 episodes — see "Incident 3" below) and has since been
rewritten with a real safety model and re-verified end-to-end against a live
73GB torrent.** Root cause: repeated manual re-triggering (stop/setLocation/
recheck called more than once across separate debugging attempts on the same
hash) raced against qBittorrent's own automatic incomplete-file management.
The fix isn't a patch on top of the old script - it's a different operating
discipline: **the whole relink is one blocking pass with no manual
re-intervention, ever**. See "Current safety model" below before touching
this again if you're tempted to run raw curl calls against a hash mid-flow -
that impulse is exactly what caused the incident.
This repo's Sonarr manages TV show organization for both Jellyfin and Plex
(they share the same `nas_media` library at `/data/video/tv`). The
`claude-homelab` plugin's Sonarr skill (if installed) only covers
search/add/remove — it has no manual-import support. This skill's `sonarr.sh`
covers that gap: matching an existing raw-named folder to the right
show/episodes and importing it into place.
Always use `./sonarr.sh` (repo root) for this — never hand-roll the Sonarr API
calls, the folder-naming token gotcha below has already bitten this workflow
once.
## Workflow: migrate one existing show
1. **Find the TVDB match**: `./sonarr.sh lookup "<show name>"` — prints
candidates as `tvdb:<id> Title (Year) status`. Pick the right one (check
year/status against what's actually in the folder).
2. **Add it**: `./sonarr.sh add tvdb:<id>` — adds unmonitored, no search, root
folder defaults to `/data/video/tv`. Skip if already in
`./sonarr.sh list`.
3. **Preview the import**: `./sonarr.sh scan "<raw folder path>"` — shows how
each file will map to season/episode, and flags anything Sonarr couldn't
parse (`!! <rejection reason>`). **Read this before importing** — don't
skip straight to step 4.
4. **Apply it**: `./sonarr.sh import "<raw folder path>"` — re-scans and
imports only if every file matched cleanly (refuses and tells you to check
`scan` output otherwise, rather than partially importing). Files are
hardlinked (not copied) into
`/data/video/tv/Show Name (Year)/Season NN/Show Name - SxxExx - Title Quality.ext`
— no extra disk usage, original folder is left behind but empty.
5. **Clean up the empty source folder**:
`docker exec jellyfin rmdir "<raw folder path>"` (any container with the
`nas_media` mount works).
6. **Re-point qBittorrent at the new location** — not automatic, see below.
Skip only if the show was never actually downloaded via qBittorrent (rare).
7. **Verify Plex picks it up correctly** — also not automatic, see below.
## Re-pointing qBittorrent after an import
Sonarr's hardlink-import removes the file from its original torrent-named
folder (only the new hardlinked copy remains) — qBittorrent's own record of
that download doesn't know this happened and silently keeps pointing at the
now-empty original path. It won't show an error immediately (a `stalledUP`
torrent doesn't re-read its files until asked to), but the next recheck or
peer request will flip it to a `missingFiles` error state.
Fix it with `./qbt-relink.sh <torrent-hash> <new-season-folder>` (repo root):
```bash
./qbt-relink.sh 8601b9b9a7164ff5038aa1f8e678e3e708eea845 "/data/video/tv/Andor (2022)/Season 02"
```
Find the hash first:
```bash
source ~/.claude-homelab/.env
bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh list \
| python3 -c "import sys,json; [print(t['hash'], t['name']) for t in json.load(sys.stdin)]" | grep -i "<show name>"
```
## Current safety model (post-rewrite)
The script runs the **entire operation as one blocking pass** — it does not
return control partway through for you to poll and come back to. It takes
minutes per multi-GB file (recheck reads the whole file over NFS), so expect
it to sit there running; that's normal, not stuck. Run it via a background
shell / `run_in_background` and wait for it to actually exit rather than
interrupting it.
**The hard rule this exists to enforce: never call `stop`/`setLocation`/
`recheck` against the same torrent hash a second time while a previous
invocation (of this script, or a raw curl call) might still be settling.**
That repeated-intervention pattern — re-triggering by hand while
investigating why something looked stuck — is what caused Incident 3's real
data loss, even though every individual safety check along the way reported
success. If a run times out (40 min) or fails partway, do not react by
re-running raw curl commands against that hash — either re-run this exact
script invocation (it's idempotent: it detects an already-correctly-relinked
torrent by checking that qBittorrent's tracked filenames actually match the
destination, not just progress, and does nothing further) or stop and
investigate read-only first (`torrents/info`, `torrents/files`, the
`/api/v2/log/main` log) before taking any write action.
What one run does, in order: stop the torrent → set its location *and*
download-path override to the season folder, each verified with a short poll
(both are asynchronous — a single immediate check can read stale data) →
pair and rename each file to match Sonarr's output (by parsed `SxxEyy`
episode number, not sort order — see below) → trigger exactly one recheck →
block, polling every 15s, until it leaves a `checking*` state (up to 40 min)
→ compare the destination folder against a filesystem manifest taken
*before* any of this started → report pass/fail. The torrent is left
**stopped** regardless of outcome — start it yourself from the WebUI once
you're satisfied, never automatically.
The pre/post filesystem manifest comparison is the real safety net — it does
not trust qBittorrent's self-reported state at all for the final verdict,
since Incident 3 demonstrated that state can look fine while real files are
gone. If the script reports a mismatch, it exits without attempting any
further remediation; investigate by hand from that known-bad state.
For a multi-season show downloaded as separate per-season torrents (e.g.
Babylon 5), run `qbt-relink.sh` once per season/torrent, pointing each at its
own `Season NN` folder — don't try to relink multiple torrents to one shared
show-root folder, since the tool matches file counts 1:1 between the torrent
and the destination folder. Kick off separate torrents' relinks as separate
background invocations if you want them running concurrently, but never two
invocations against the *same* hash at once.
**Known limitation**: if Sonarr split a single torrent's files across two
destination folders (e.g. a season pack that included specials, which Sonarr
routed to a separate `Specials`/`Season 00` folder), `qbt-relink.sh` can't
handle that in one call — it only knows about one `<new-folder>`. Check the
file count first (`torrents/files` count vs. `ls` count in the season
folder); if they don't match because of a specials split, this needs doing
by hand (or extending the tool) rather than forcing it.
### Incident 2: a stale `download_path` override kept recheck pointed at the wrong place entirely
Even after incident 1's fix (checked HTTP status, confirmed `save_path`
via a follow-up GET), three of the nine torrents in the same batch
(Babylon 5 S03/S04/S05) still failed — `setLocation` genuinely succeeded and
`save_path` genuinely updated, but their **`content_path`** (what qBittorrent
actually reads during a recheck) stayed pointed at
`/data/torrents/incomplete/video/tv`, qBittorrent's global incomplete-files
staging path (`temp_path`, from `app/preferences`). Cause: earlier in the
same incident, these three torrents had briefly been flagged as needing to
download again (a race between the bad first relink attempt and this
corrective one — see the qBittorrent log via `/api/v2/log/main` for
`"Torrent move canceled"` / duplicate `"Start moving torrent"` entries if
this happens again), which set a **per-torrent `download_path` override**
that `setLocation` does not clear. `content_path` silently followed that
stale override instead of `save_path` — so the recheck kept validating
against an empty folder and would have re-downloaded again, exactly like
incident 1, had it not been caught by watching state directly rather than
trusting the script's "Done" message.
Fix: `torrents/setDownloadPath` (note — takes `id`, not `hashes`, unlike
every other endpoint used here) clears the override, and `qbt-relink.sh` now
calls it and verifies `content_path` (not just `save_path`) actually landed
under the target folder before proceeding to rename/recheck. **If a torrent
ever seems stuck at `stoppedDL`/0% far longer than others in the same batch,
or its `state` is `checkingDL` instead of `checkingUP`**, check
`content_path` vs `save_path` directly — a mismatch means the recheck is
running against the wrong location and will "succeed" at finding nothing.
### Incident 3: qBittorrent's own automatic file management destroyed real data despite every check passing
**Status: fixed and re-verified (2026-09-09)** — see "Current safety model"
above. Root cause turned out to be repeated manual re-triggering (multiple
separate stop/setLocation/recheck calls against the same hash while
debugging), not a flaw in any single check. The rewritten script enforces a
single blocking pass with no re-intervention, and was re-verified end-to-end
against a live 73GB torrent, including catching a real stale-state bug in
its own idempotency check during that testing (see below).
2026-09-08, same overall session, later batch. Even with incidents 1 and 2's
fixes in place (`api_call` status checking, torrent stopped first,
`save_path` *and* `content_path` both confirmed correct before proceeding),
Babylon 5 S03 and S04 lost their actual files. The qBittorrent log
(`/api/v2/log/main`) tells the story: after `setLocation` moved them to
`Season 03`/`Season 04` (logged success), qBittorrent's *own* internal logic
immediately enqueued and executed a further move of those same files **back**
to `/data/torrents/incomplete/video/tv` on its own — nothing in the script
requested this. A later resume cycle moved them back to `Season 03`/`04`
again, but then repeated automatic resume/stop cycles followed (visible as
alternating `"Torrent resumed"`/`"Torrent stopped"` log lines with no
corresponding script action), and after those, both `Season 03` and
`Season 04` folders were **completely empty** — confirmed via `ls`/`stat`
from two separate containers, and a full search of `/data` found the files
nowhere. 44 episodes, gone. No backup existed (the user had explicitly
decided to proceed without one earlier in this project).
Best-guess mechanism: qBittorrent's `temp_path_enabled` incomplete-file
management re-evaluates on every resume, independent of manual
`setLocation`/`setDownloadPath` calls, and can decide to relocate — or,
worse, truncate/overwrite in place expecting fresh downloaded data — files
at a location it still considers "incomplete" (i.e. never successfully
verified as 100% via a *clean* recheck). Since these torrents' recheck kept
getting interrupted/retriggered across multiple attempts (network/NFS
slowness caused several manual re-triggers in this session), the torrent
seems to have never reached a stable "verified complete" state internally,
leaving it perpetually eligible for this automatic (and here, destructive)
relocation — regardless of what the script's own state checks reported.
**This means the safety checks this script relies on (`save_path`,
`content_path`, HTTP status) are necessary but not sufficient** — they
confirm the *script's* requests succeeded, but qBittorrent can still take
further unrequested action afterward that undoes or destroys the result,
and none of that is visible to a script that only checks immediately after
its own calls. `qbt-relink.sh` is paused (see the warning at the top of this
file) until this is understood well enough to prevent it — likely candidates
for a real fix: disabling `temp_path_enabled` globally before relinking
(and confirming no other torrent depends on it) as a global preference, an
approach that fully separates the qBittorrent-tracked download from the
Sonarr-organized library so they never share a path, or verifying via a
polling loop that a recheck reaches a genuinely stable end state (not just
"currently reports 100% right now") before considering a torrent safe.
### Incident 1: an unchecked `setLocation` call let a torrent start re-downloading
The first version of `qbt-relink.sh` fired `setLocation`/`renameFile`/
`recheck` with `curl -s ... > /dev/null`, discarding the HTTP status. Running
it across 9 torrents in a batch, one `setLocation` call silently failed (the
torrent still hadn't been re-pointed) while the script declared success
anyway. Its recheck then ran against the original path — already empty,
since Sonarr's import had removed the file — found 0% of pieces present, and
qBittorrent started **re-downloading the entire torrent from scratch** into
qBittorrent's default incomplete-files staging path
(`/data/torrents/incomplete/...`), racking up several minutes of real
download traffic before it was caught and stopped.
No actual harm resulted — the fresh download went to a separate staging path
and never touched the Sonarr-organized hardlinked copy (confirmed by
checking file counts and Plex/disk state directly), and the stray partial
files were deleted — but it could have gone worse (wasted bandwidth on a
private tracker, or worse, if the download target had somehow overlapped
with the real file). Root cause: no HTTP status checking, and the torrent
was never stopped before being touched. Both are now fixed in the script
(the `api_call` helper checks every response; the torrent is stopped before
any location/rename calls and stays stopped after recheck) — but the lesson
generalizes: **when scripting a batch of mutating API calls, verify each one
actually took effect before moving to the next, especially anything that
could make a client start writing data on its own.**
Credentials (`QBITTORRENT_URL`/`USERNAME`/`PASSWORD`) live in
`~/.claude-homelab/.env` (the claude-homelab plugin's credential file), not
this repo's `.credentials` — `qbt-relink.sh` sources that file directly.
## Verifying the Plex side (do this every time, not just once)
Moving a file changes its path. Plex's database still points at the *old*
path until it rescans — it does not watch the filesystem live here. After
every import:
```bash
TOKEN=$(docker exec plex grep -o 'PlexOnlineToken="[^"]*"' \
"/config/Library/Application Support/Plex Media Server/Preferences.xml" | cut -d'"' -f2)
curl -s "http://localhost:32400/library/sections/2/refresh?X-Plex-Token=$TOKEN"
```
(Section `2` is "TV Shows" in this Plex instance — confirm with
`curl -s "http://localhost:32400/library/sections?X-Plex-Token=$TOKEN"` if
unsure.) Then spot-check that the show's episodes still show the correct
`viewCount`/watch state pointing at the new file path — Plex re-matches by
its own metadata agent (title/GUID), not by the old path, so watched status
normally survives a move, but confirm it rather than assuming, especially for
messier shows (duplicate releases, specials, date-based naming).
## Known gotcha: `{Year}` is not a valid Sonarr token
The series folder format must use the combined token `{Series TitleYear}`
(produces `Title (Year)` as one unit) — `{Series Title} ({Year})` silently
produces `Title ()` with no year, because Sonarr has no standalone `{Year}`
token for series-level naming. This bit the first two shows migrated
(`Andor`, `3 Body Problem`) before being caught — check
`docker exec sonarr curl -s http://localhost:8989/api/v3/config/naming/examples -H "X-Api-Key: $SONARR_API_KEY"`
(or `curl "$SONARR_URL/api/v3/config/naming/examples" -H "X-Api-Key: $SONARR_API_KEY"`
from the host) any time naming config changes — `seriesFolderExample` should
show a real year, not `()`.
## Edge cases that will need manual handling (not automatable via scan/import)
- **Date-based shows** (e.g. Jeopardy) — no season/episode numbers in
filenames. Set the show's Series Type to "Daily" in the Sonarr UI before
scanning; `scan` will otherwise reject every file.
- **Specials mixed into a season folder** (e.g. Lower Decks `S00E501`-style
files) — Sonarr should route these to `Season 00` correctly; verify rather
than assume.
- **Duplicate episodes from two releases** (e.g. Poker Face) — decide which
release to keep before importing; `scan` will show both, `import` will
refuse until resolved since it won't guess.
- **Combined/ambiguous episode files** (e.g. `e01-02.` with no season number)
— `scan` will likely reject these; use the Sonarr web UI's Manual Import
screen instead, which allows manually assigning an episode range per file
(the CLI script only handles clean auto-matches by design, so it can't
silently mis-import something ambiguous).
## Other commands
- `./sonarr.sh list` — series currently in Sonarr
- `./sonarr.sh rootfolders` — root folders + how many unmapped (not-yet-added)
folders each has
- `./sonarr.sh queue` — current download queue
- `./sonarr.sh test-client` — tests configured download client connections
(currently just qBittorrent_vpn, category `tv-sonarr`)
## Credentials
`SONARR_API_KEY` / `SONARR_URL` live in this repo's `.credentials` (source it
before running `sonarr.sh` manually outside the script — the script sources
it itself). Get a fresh key from Sonarr UI → Settings → General → Security,
or `docker exec sonarr grep -o 'ApiKey>[^<]*' /config/config.xml`.
+100
View File
@@ -0,0 +1,100 @@
---
name: subwave
description: Manage SUB/WAVE (self-hosted AI radio station) shows, DJ personas, the weekly schedule grid, and live overrides. Use for any SUB/WAVE admin-API work — creating a new anchor show, minting a DJ persona, scheduling a slot, or triggering a show live.
---
# SUB/WAVE admin API
A CLI wrapper (`subwave_api.py`, in this skill's directory) around the
`sub-wave-controller` admin REST API, distilled from a long session of
building ~30 anchor-playlist shows and DJ personas by hand with raw
`docker exec curl` calls. Use it instead of re-deriving the request shapes
each time.
## Setup
Auth comes from `subwave/.env` (`ADMIN_USER`/`ADMIN_PASS`) at the repo root —
no flags needed. **The admin API (port 7701) is not published to the host** —
`docker ps` shows `sub-wave-controller` with only `7701/tcp` (internal), not
`0.0.0.0:7701->7701`. Every call must go through
`docker exec sub-wave-controller curl ...`, which is exactly what this script
does. Don't waste a round trip re-checking this — it's a deliberate
container-internal-only setup, not a misconfiguration.
Run it as: `python3 .claude/skills/subwave/subwave_api.py <command> ...`
## Commands
```
persona list
persona create --name N --voice V --tagline T --soul S
[--frequency silent|quiet|moderate|chatty|aggressive]
[--humour 0-10] [--warmth 0-10] [--local-colour 0-10]
show list
show get --id ID
show create --name N --topic T --playlist-id ID
[--genres a,b,c] [--moods a,b] [--energies a,b]
[--eras 1990-2000,2005-2010] [--persona-id ID]
show set-persona --id ID --persona-id ID
show set-name --id ID --name N
show set-topic --id ID --topic T
schedule grid # full 7x24 weekly grid, show names
schedule open-slots # every null (day, hour) pair
schedule set --show-id ID --day 0-6 --hour 0-23 # day 0 = Sunday
schedule set-random --show-id ID # pick a random open slot
trigger --show-id ID [--minutes 60] # live override, starts immediately
```
Playlists themselves (search/curate/create anchor playlists in Navidrome) are
a separate concern — use the **navidrome** skill for that, then pass the
resulting playlist id to `show create --playlist-id`.
## Gotchas learned the hard way
- **`POST /shows` needs the show wrapped in `{"show": {...}}`** — posting the
show object bare fails with a schema error (`expected object, received
undefined`). The CLI handles this.
- **New shows need a full show object, not a partial patch** — there's no
PATCH endpoint. To change one field (persona, name, topic), read the full
current show from `schedule.json`, mutate the one field, and re-POST the
whole thing. `show set-persona`/`set-name`/`set-topic` do exactly this.
- **`PUT /schedule` takes the *entire* weekly grid**, not a single slot —
read the current grid, mutate one `[day][hour]` cell, PUT the whole thing
back. `schedule set`/`set-random` do this for you. The response's
`dropped` count should always be `0`; a nonzero value means a show id in
the grid doesn't exist anymore (a bug elsewhere, not addressed here).
- **`POST /settings` for a new persona is also whole-array append**, not a
single-persona-create endpoint — there is no such endpoint. Read
`personas`, append one object, POST `{"personas": [...]}`.
- **Persona `frequency` is a strict enum**: `silent | quiet | moderate |
chatty | aggressive`. Passing `"normal"` (an easy guess) fails with `must
be one of: silent, quiet, moderate, chatty, aggressive` — the CLI defaults
to `moderate` to sidestep this.
- **A show with no explicit `personaId` inherits whatever `p_default0` is**
(one of the original 3 built-in personas) — and if that persona has
`djMode: false`, the show runs with **zero DJ talk breaks** and sounds
silent/DJ-less. Always set a real persona explicitly once you know what
voice/character you want; don't assume a blank field means "no host," it
means "the accidental default host."
- **`/schedule/override` (the live-trigger endpoint) starts immediately** —
`startedAt: Date.now()`. There's no way to schedule a *future* start time
through this endpoint; "play this at 9pm" only works if it's already ~9pm,
or by placing the show in the recurring weekly grid at that hour instead.
- **Two shows can't cleanly share one weekly slot** — if you want two moods
to alternate in the same hour across the week (e.g. show A on
Sun/Tue/Thu/Sat, show B on Mon/Wed/Fri), that's just setting the same
`[hour]` column to different show ids on different `day` rows via repeated
`schedule set` calls — there's no "alternate" primitive, just per-day-row
assignment.
- **Kokoro voice pool has ~28 usable English voices** (`af_*`/`am_*`
American, `bf_*`/`bm_*` British) sharing one 27MB `voices-v1.0.bin` file —
installing/using more costs nothing. `PERSONA_LIMIT = 48` is the only hard
ceiling (`/app/src/schemas/persona.ts` inside the controller container).
- **A library rescan can surface artists that "don't exist" on a first
check** — Navidrome's index lags real disk state. If a user swears an
artist should be there, `navidrome_api.py`-equivalent rescan
(`startScan`/`getScanStatus` via the Subsonic API) before concluding it's
actually missing.
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""SUB/WAVE admin-API CLI. Auth from subwave/.env, calls proxied through
`docker exec sub-wave-controller curl ...` because the controller's admin API
(port 7701) is not published to the host -- only reachable inside the
sub-wave-controller container itself.
Usage:
subwave_api.py persona list
subwave_api.py persona create --name N --voice V --tagline T --soul S
[--frequency silent|quiet|moderate|chatty|aggressive]
[--humour 0-10] [--warmth 0-10] [--local-colour 0-10]
[--script-length concise|...] [--link-style natural|...]
subwave_api.py show list
subwave_api.py show get --id ID
subwave_api.py show create --name N --topic T --playlist-id ID
[--genres a,b,c] [--moods a,b] [--energies a,b]
[--eras 1990-2000,2005-2010] [--persona-id ID]
subwave_api.py show set-persona --id ID --persona-id ID
subwave_api.py show set-name --id ID --name N
subwave_api.py show set-topic --id ID --topic T
subwave_api.py schedule grid
subwave_api.py schedule open-slots
subwave_api.py schedule set --show-id ID --day 0-6 --hour 0-23
subwave_api.py schedule set-random --show-id ID
subwave_api.py trigger --show-id ID [--minutes 60]
"""
import argparse
import json
import subprocess
import sys
import random
ENV_PATH = "/home/poprhythm/docker-infrastructure/subwave/.env"
STATE_SHOWS = "/srv/subwave/state/schedule.json"
STATE_SETTINGS = "/srv/subwave/state/settings.json"
CONTAINER = "sub-wave-controller"
BASE_URL = "http://localhost:7701"
def load_creds():
creds = {}
with open(ENV_PATH) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
creds[k] = v
return creds["ADMIN_USER"], creds["ADMIN_PASS"]
def api_call(method, path, body=None):
"""Runs curl inside the controller container. Body, if given, is written
to a temp file on the host, docker cp'd in, and cleaned up after -- avoids
quoting hell with large/nested JSON on the command line."""
user, password = load_creds()
if body is None:
cmd = [
"docker", "exec", CONTAINER, "sh", "-c",
f"curl -s -u '{user}:{password}' -X {method} {BASE_URL}{path}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
else:
import tempfile
import os
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump(body, f)
local_path = f.name
remote_path = f"/tmp/{os.path.basename(local_path)}"
try:
subprocess.run(["docker", "cp", local_path, f"{CONTAINER}:{remote_path}"], check=True)
cmd = [
"docker", "exec", CONTAINER, "sh", "-c",
f"curl -s -u '{user}:{password}' -X {method} {BASE_URL}{path} "
f"-H 'Content-Type: application/json' -d @{remote_path}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
finally:
subprocess.run(["docker", "exec", CONTAINER, "rm", "-f", remote_path],
capture_output=True)
os.unlink(local_path)
if result.returncode != 0:
raise RuntimeError(f"docker exec failed: {result.stderr}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"non-JSON response: {result.stdout[:500]}")
def load_state(path):
with open(path) as f:
return json.load(f)
def parse_eras(s):
eras = []
for part in s.split(","):
a, b = part.split("-")
eras.append({"fromYear": int(a), "toYear": int(b)})
return eras
# ---- persona ----
def cmd_persona_list(args):
d = load_state(STATE_SETTINGS)
for p in d["personas"]:
voice = p.get("tts", {}).get("voice", "")
print(f"{p['id']} | {p['name']:<12} | {voice:<12} | djMode={p.get('djMode')}")
def cmd_persona_create(args):
d = load_state(STATE_SETTINGS)
new_persona = {
"name": args.name,
"tagline": args.tagline,
"frequency": args.frequency,
"scriptLength": args.script_length,
"djMode": True,
"linkStyle": args.link_style,
"humour": args.humour,
"localColour": args.local_colour,
"warmth": args.warmth,
"soul": args.soul,
"language": "",
"avatar": "",
"tts": {"engine": "kokoro", "cloudProvider": "openai", "voice": args.voice,
"gainDb": 0, "speed": 1},
"skills": None,
"tags": [],
}
personas = d["personas"] + [new_persona]
resp = api_call("POST", "/settings", {"personas": personas})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
d2 = load_state(STATE_SETTINGS)
for p in d2["personas"]:
if p["name"] == args.name and p not in d["personas"]:
print(f"created '{args.name}': {p['id']} (voice={args.voice})")
return
print(f"created '{args.name}' (re-read settings to confirm id)")
# ---- show ----
def cmd_show_list(args):
d = load_state(STATE_SHOWS)
for s in d["shows"]:
print(f"{s['id']} | {s['name']}")
def cmd_show_get(args):
d = load_state(STATE_SHOWS)
for s in d["shows"]:
if s["id"] == args.id:
print(json.dumps(s, indent=2))
return
print(f"no such show: {args.id}", file=sys.stderr)
sys.exit(1)
def cmd_show_create(args):
show = {
"name": args.name,
"topic": args.topic,
"personaId": args.persona_id or "p_default0",
"guestPersonaIds": [],
"banter": False,
"pauseTalk": False,
"programme": False,
"segmentSkill": "",
"moods": args.moods.split(",") if args.moods else [],
"themeId": "",
"genres": args.genres.split(",") if args.genres else [],
"energies": args.energies.split(",") if args.energies else [],
"eras": parse_eras(args.eras) if args.eras else [],
"vocals": "",
"filtersStrict": True,
"maxTrackSeconds": args.max_track_seconds,
"minTrackLengthSeconds": None,
"fadeAtShowEnd": None,
"playlistIds": [args.playlist_id],
"playlistStrict": True,
"playlistExhaust": True,
"excludedPlaylistIds": [],
"tags": [],
}
resp = api_call("POST", "/shows", {"show": show})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
saved = resp.get("show", {})
print(f"created '{args.name}': {saved.get('id')}")
def _update_show_field(show_id, field, value):
d = load_state(STATE_SHOWS)
for s in d["shows"]:
if s["id"] == show_id:
s = dict(s)
s[field] = value
resp = api_call("POST", "/shows", {"show": s})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
print(f"{show_id}: {field} -> {value}")
return
print(f"no such show: {show_id}", file=sys.stderr)
sys.exit(1)
def cmd_show_set_persona(args):
_update_show_field(args.id, "personaId", args.persona_id)
def cmd_show_set_name(args):
_update_show_field(args.id, "name", args.name)
def cmd_show_set_topic(args):
_update_show_field(args.id, "topic", args.topic)
# ---- schedule ----
def cmd_schedule_grid(args):
d = load_state(STATE_SHOWS)
names = {s["id"]: s["name"] for s in d["shows"]}
for day in "0123456":
row = [names.get(v, "null") if v else "null" for v in d["schedule"][day]]
print(f"day {day}:", row)
def cmd_schedule_open_slots(args):
d = load_state(STATE_SHOWS)
open_slots = []
for day in "0123456":
for h, v in enumerate(d["schedule"][day]):
if v is None:
open_slots.append((day, h))
print(f"{len(open_slots)} open slots")
for day, h in open_slots:
print(f" day {day} hour {h}")
def cmd_schedule_set(args):
d = load_state(STATE_SHOWS)
schedule = d["schedule"]
schedule[str(args.day)][args.hour] = args.show_id
resp = api_call("PUT", "/schedule", {"schedule": schedule})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
print(f"set day {args.day} hour {args.hour} -> {args.show_id} (dropped={resp.get('dropped')})")
def cmd_schedule_set_random(args):
d = load_state(STATE_SHOWS)
schedule = d["schedule"]
open_slots = [(day, h) for day in "0123456" for h, v in enumerate(schedule[day]) if v is None]
if not open_slots:
print("no open slots", file=sys.stderr)
sys.exit(1)
day, h = random.choice(open_slots)
schedule[day][h] = args.show_id
resp = api_call("PUT", "/schedule", {"schedule": schedule})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
print(f"set day {day} hour {h} -> {args.show_id} (dropped={resp.get('dropped')})")
# ---- trigger ----
def cmd_trigger(args):
resp = api_call("POST", "/schedule/override",
{"showId": args.show_id, "minutes": args.minutes, "until": "fixed"})
if "error" in resp:
print(f"error: {resp['error']}", file=sys.stderr)
sys.exit(1)
print(json.dumps(resp))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd", required=True)
persona = sub.add_parser("persona")
persona_sub = persona.add_subparsers(dest="subcmd", required=True)
p = persona_sub.add_parser("list")
p.set_defaults(func=cmd_persona_list)
p = persona_sub.add_parser("create")
p.add_argument("--name", required=True)
p.add_argument("--voice", required=True, help="Kokoro voice id, e.g. am_puck")
p.add_argument("--tagline", required=True)
p.add_argument("--soul", required=True)
p.add_argument("--frequency", default="moderate",
choices=["silent", "quiet", "moderate", "chatty", "aggressive"])
p.add_argument("--script-length", default="concise")
p.add_argument("--link-style", default="natural")
p.add_argument("--humour", type=int, default=5)
p.add_argument("--warmth", type=int, default=6)
p.add_argument("--local-colour", type=int, default=5)
p.set_defaults(func=cmd_persona_create)
show = sub.add_parser("show")
show_sub = show.add_subparsers(dest="subcmd", required=True)
p = show_sub.add_parser("list")
p.set_defaults(func=cmd_show_list)
p = show_sub.add_parser("get")
p.add_argument("--id", required=True)
p.set_defaults(func=cmd_show_get)
p = show_sub.add_parser("create")
p.add_argument("--name", required=True)
p.add_argument("--topic", required=True)
p.add_argument("--playlist-id", required=True, help="Navidrome playlist id (the anchor)")
p.add_argument("--genres", default="")
p.add_argument("--moods", default="")
p.add_argument("--energies", default="")
p.add_argument("--eras", default="", help="e.g. 1990-1995,2000-2005")
p.add_argument("--persona-id", default="")
p.add_argument("--max-track-seconds", type=int, default=None)
p.set_defaults(func=cmd_show_create)
p = show_sub.add_parser("set-persona")
p.add_argument("--id", required=True)
p.add_argument("--persona-id", required=True)
p.set_defaults(func=cmd_show_set_persona)
p = show_sub.add_parser("set-name")
p.add_argument("--id", required=True)
p.add_argument("--name", required=True)
p.set_defaults(func=cmd_show_set_name)
p = show_sub.add_parser("set-topic")
p.add_argument("--id", required=True)
p.add_argument("--topic", required=True)
p.set_defaults(func=cmd_show_set_topic)
schedule = sub.add_parser("schedule")
schedule_sub = schedule.add_subparsers(dest="subcmd", required=True)
p = schedule_sub.add_parser("grid")
p.set_defaults(func=cmd_schedule_grid)
p = schedule_sub.add_parser("open-slots")
p.set_defaults(func=cmd_schedule_open_slots)
p = schedule_sub.add_parser("set")
p.add_argument("--show-id", required=True)
p.add_argument("--day", type=int, required=True, help="0=Sunday .. 6=Saturday")
p.add_argument("--hour", type=int, required=True, help="0-23")
p.set_defaults(func=cmd_schedule_set)
p = schedule_sub.add_parser("set-random")
p.add_argument("--show-id", required=True)
p.set_defaults(func=cmd_schedule_set_random)
p = sub.add_parser("trigger")
p.add_argument("--show-id", required=True)
p.add_argument("--minutes", type=int, default=60)
p.set_defaults(func=cmd_trigger)
args = parser.parse_args()
try:
args.func(args)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+36 -7
View File
@@ -8,8 +8,8 @@ Summary of how each service connects to the unRAID NAS (192.168.1.192 / 192.168.
| Strategy | Services | Reliability | Notes | | Strategy | Services | Reliability | Notes |
|----------|----------|-------------|-------| |----------|----------|-------------|-------|
| Docker NFS named volume | Plex, qBittorrent, Audiobookshelf | ⚠️ Fragile | Soft mount; stale handles on drive spin-down | | Docker NFS named volume | Plex, qBittorrent | ⚠️ Fragile | Soft mount; stale handles on drive spin-down |
| systemd permanent mount + bind | Calibre (import), oCIS, Immich | ✅ Solid | Hard NFS; permanent; no idle cycling | | systemd permanent mount + bind | Calibre (import), oCIS, Immich, Audiobookshelf | ✅ Solid | Hard NFS; permanent; no idle cycling |
| systemd automount + bind | Backrest, Filebrowser-Colleen-HD | ✅ Solid | CIFS; TimeoutIdleSec=0; never unmounts once triggered | | systemd automount + bind | Backrest, Filebrowser-Colleen-HD | ✅ Solid | CIFS; TimeoutIdleSec=0; never unmounts once triggered |
| Local SSD + lsyncd sync | Calibre (library) | ✅ Best | SQLite never touches NFS; NAS copy is read-only replica | | Local SSD + lsyncd sync | Calibre (library) | ✅ Best | SQLite never touches NFS; NAS copy is read-only replica |
@@ -84,9 +84,11 @@ Summary of how each service connects to the unRAID NAS (192.168.1.192 / 192.168.
- **Database**: PostgreSQL on local SSD (`/var/lib/postgresql/data`) — correct, not on NFS - **Database**: PostgreSQL on local SSD (`/var/lib/postgresql/data`) — correct, not on NFS
### Audiobookshelf ### Audiobookshelf
- **Strategy**: Docker NFS named volume - **Strategy**: systemd permanent NFS mount + Docker bind mount (fixed 2026-09-17, was previously on `mnt-nas_audiobooks.automount`)
- **NAS path**: `192.168.1.192:/mnt/user/media/audiobooks` - **Host mount**: `/mnt/nas_audiobooks` (NFS `192.168.1.192:/mnt/user/media/audiobooks`)
- **Container path**: `/audiobooks` - **Container path**: `/audiobooks`
- **Mount options**: `nfsvers=3,hard,rw,noatime`
- **Why permanent (not automount)**: see "Docker bind mounts + automount = stale handles" below - this share was on autofs and flapped (ESTALE) on a roughly hourly cadence for days (18 remounts in 82 min on 2026-09-15, 13 in 67 min and then continuous through several more hours on 2026-09-17) before being switched to a permanent mount, exactly matching this repo's own documented gotcha for this pattern.
- **Config/metadata**: Local SSD (`/srv/audiobookshelf/`) — correct - **Config/metadata**: Local SSD (`/srv/audiobookshelf/`) — correct
--- ---
@@ -98,6 +100,7 @@ Summary of how each service connects to the unRAID NAS (192.168.1.192 / 192.168.
| `mnt-nas_books.mount` | NFS permanent | `:/mnt/user/media/books` | enabled, always up | | `mnt-nas_books.mount` | NFS permanent | `:/mnt/user/media/books` | enabled, always up |
| `mnt-nas_owncloud.mount` | NFS permanent | `:/mnt/user/owncloud` | enabled, always up | | `mnt-nas_owncloud.mount` | NFS permanent | `:/mnt/user/owncloud` | enabled, always up |
| `mnt-nas_family.mount` | NFS permanent | `:/mnt/user/family` | enabled, always up | | `mnt-nas_family.mount` | NFS permanent | `:/mnt/user/family` | enabled, always up |
| `mnt-nas_audiobooks.mount` | NFS permanent | `:/mnt/user/media/audiobooks` | enabled, always up (was `.automount` until 2026-09-17 - see Known Issues) |
| `mnt-nas_library.mount` | CIFS automount | `//192.168.1.192/library` | triggered on access, TimeoutIdleSec=0 | | `mnt-nas_library.mount` | CIFS automount | `//192.168.1.192/library` | triggered on access, TimeoutIdleSec=0 |
| `mnt-nas_library.automount` | CIFS automount | — | enabled | | `mnt-nas_library.automount` | CIFS automount | — | enabled |
| `mnt-nas_backup.mount` | CIFS automount | `//192.168.1.192/backup` | triggered on access, TimeoutIdleSec=0 | | `mnt-nas_backup.mount` | CIFS automount | `//192.168.1.192/backup` | triggered on access, TimeoutIdleSec=0 |
@@ -107,17 +110,43 @@ Summary of how each service connects to the unRAID NAS (192.168.1.192 / 192.168.
## Known Issues & Gotchas ## Known Issues & Gotchas
- **Docker NFS named volumes**: The `nas_media` volume uses `soft,nolock,timeo=14,nfsvers=4`. This is fragile — short timeout means EIO on spin-up. Plex/qBittorrent survive because they open/close handles per request. If either starts having I/O errors, migrate to the systemd permanent mount pattern. - **Docker NFS named volumes**: The `nas_media` volume uses `soft,nolock,timeo=14,vers=3`. This is fragile — short timeout means EIO on spin-up. Plex/qBittorrent survive because they open/close handles per request. If either starts having I/O errors, migrate to the systemd permanent mount pattern.
- **Docker bind mounts + automount = stale handles**: Docker snapshots the mount reference at container start. If autofs cycles (unmount + remount) between container restarts, the container's bind mount goes stale while the host sees the path fine. Fix: use permanent `.mount` (no `.automount`) for any share that Docker containers bind-mount. - **Docker's local NFS volume driver requires `host:/path` in `device`, and can't do NFSv4**: it calls `mount(2)` directly rather than shelling out to the `mount.nfs`/`mount.nfs4` userspace helper. Two consequences, both hit in the 2026-09-16 outage below: (1) nfs-utils 2.6.4 (Ubuntu 24.04) rejects the old `device=":/path"` + separate `o=addr=<ip>` split form - the host must be in the device string itself now (`device=<ip>:/path`); (2) `vers=4`/`nfsvers=4` fails with "protocol not supported" via the raw syscall even though a manual `mount -t nfs -o nfsvers=4 ...` succeeds (that goes through `mount.nfs`, which does version negotiation the raw syscall skips). Stick to `vers=3` for any Docker-managed NFS volume on this host.
- **Docker bind mounts + automount = stale handles**: Docker snapshots the mount reference at container start. If autofs cycles (unmount + remount) between container restarts, the container's bind mount goes stale while the host sees the path fine. Fix: use permanent `.mount` (no `.automount`) for any share that Docker containers bind-mount. **Hit in practice 2026-09-17**: `mnt-nas_audiobooks.mount` had been left on an `.automount` unit (autofs, `TimeoutIdleSec=0`) instead of the permanent-mount pattern used by every other Docker-bind-mounted NFS share. It flapped ESTALE on a recurring, multi-hour cadence over several days (self-healed every ~5 min by `nfs-mount-heal.sh`, but never actually fixed) even though `TimeoutIdleSec=0` should prevent autofs idle expiry - autofs can still cycle the underlying mount for reasons other than idle timeout, and any such cycle invalidates the container's bind-mounted reference exactly as this gotcha describes. Fix: `systemctl disable --now mnt-nas_audiobooks.automount`, delete the unit file, `systemctl enable --now mnt-nas_audiobooks.mount` directly (matching nas_family/nas_books/nas_owncloud exactly).
- **`nfs-mount-heal.sh`'s own health check can generate false "stale" verdicts on shares with large files**: it `find`s the first file in the share and reads it to prove the mount is alive. Fine for immich (13-byte `.immich` marker) and oCIS (0-byte `.migrations.lock`), but `nas_audiobooks` has no small marker file, so `find` legitimately picked a real audiobook - **found 2026-09-18 to be 2.9GB**. The check `cat`'d the whole thing inside a 10s timeout, which can never finish regardless of mount health, producing `cat exit=124` "stale" verdicts on a mount that (after the automount fix above) was actually fine. This was the second, independent cause of the audiobooks flapping continuing after the automount fix - the two bugs were stacked. Fixed by reading only `head -c 65536` instead of the whole file; the script also now logs the real find/head error text instead of a generic guess. If a share's first-found file is ever large, this class of false positive can recur - the safer long-term fix would be to always test against a small dedicated marker file per share, matching immich/oCIS.
- **SQLite on NFS**: Never store SQLite databases (calibre `metadata.db`/`notes.db`, any app DB) on NFS. Even with `hard` mounts and NLM locking, transient NFS errors cause `SQLITE_IOERR`. Keep SQLite on local SSD; sync non-SQLite files to NAS if sharing is needed. - **SQLite on NFS**: Never store SQLite databases (calibre `metadata.db`/`notes.db`, any app DB) on NFS. Even with `hard` mounts and NLM locking, transient NFS errors cause `SQLITE_IOERR`. Keep SQLite on local SSD; sync non-SQLite files to NAS if sharing is needed.
- **CIFS vs NFS for file permissions**: CIFS enforces server-side ACLs based on the SMB user. Files created via NFS by uid=99 with mode 600 are inaccessible via CIFS. Use NFS when container needs uid-mapped access to NFS-created files. - **CIFS vs NFS for file permissions**: CIFS enforces server-side ACLs based on the SMB user. Files created via NFS by uid=99 with mode 600 are inaccessible via CIFS. Use NFS when container needs uid-mapped access to NFS-created files.
- **lsyncd for NAS sync**: inotify-based, syncs within ~15 seconds of any write. Config at `/etc/lsyncd/lsyncd.conf.lua`. Log at `/var/log/lsyncd.log`. - **lsyncd for NAS sync**: inotify-based, syncs within ~15 seconds of any write. Config at `/etc/lsyncd/lsyncd.conf.lua`. Log at `/var/log/lsyncd.log`.
--- ---
## Self-Healing (nas_family / nas_books / nas_owncloud / nas_audiobooks)
As of 2026-08-25, stale-handle recovery for immich, calibre, and oCIS is
automated instead of requiring manual `systemctl restart` + `docker restart`
(audiobookshelf added 2026-09-16, and its underlying mount fixed to actually
stop going stale on 2026-09-17 - see Known Issues):
- `<container>-mount-ready.service` (`immich-mount-ready`, `calibre-mount-ready`,
`ocis-mount-ready`, `audiobookshelf-mount-ready`) — `BindsTo=mnt-nas_*.mount`,
restarts the container whenever its mount unit restarts.
- `nfs-mount-heal.timer` (every 5 min) → `nfs-mount-heal.sh` — does a nested
file read (not just `ls` the mount root, which can succeed even when nested
handles are stale) against each of the four mounts. On failure it force-
remounts the mount unit, which cascades into the container restart above,
then sends a Telegram alert. This is a safety net for genuine transient
staleness - it is not a substitute for fixing a mount that's flapping for
a structural reason (e.g. sitting on autofs when it shouldn't be).
Host files backed up at `system-config/nfs-self-heal/` in this repo (see its
README for the reinstall procedure — these are systemd units, not
docker-compose, so they don't redeploy via Portainer).
---
## Recommended Migration (future) ## Recommended Migration (future)
Plex, qBittorrent, Immich, and Audiobookshelf all use the fragile Docker NFS named volume pattern. The safer pattern is systemd permanent mount + Docker bind mount (as used by calibre-import and oCIS). This would involve: Plex and qBittorrent still use the fragile Docker NFS named volume pattern (Immich and Audiobookshelf have both since migrated off it). The safer pattern is systemd permanent mount + Docker bind mount (as used by calibre-import, oCIS, and now audiobookshelf). This would involve:
1. Create `/mnt/nas_media` systemd permanent mount unit 1. Create `/mnt/nas_media` systemd permanent mount unit
2. Update compose files to use bind mounts instead of the external `nas_media` volume 2. Update compose files to use bind mounts instead of the external `nas_media` volume
3. `docker volume rm nas_media` after redeployment 3. `docker volume rm nas_media` after redeployment
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Pull recent Falco alerts from netdata and write them as a Dashy
custom-list JSON file, so the homelab dashboard shows a live feed of
security events without needing Falcosidekick/Redis/a new UI.
Run periodically via cron (see repo README/crontab entry). Reads from
netdata's existing alert-transitions API - the same data already driving
the Telegram pipeline - so this adds zero new attack surface or
dependencies.
"""
import datetime
import json
import time
import urllib.request
from zoneinfo import ZoneInfo
NETDATA_URL = "http://192.168.1.67:19999"
OUTPUT_PATH = "/srv/dashy/json-data/falco-alerts.json"
LOOKBACK_SECONDS = 7 * 86400 # 7 days
MAX_ENTRIES = 5
LOCAL_TZ = ZoneInfo("America/New_York")
def fetch_transitions():
now = int(time.time())
after = now - LOOKBACK_SECONDS
url = (
f"{NETDATA_URL}/api/v2/alert_transitions"
f"?after={after}&before={now}&last=500"
)
with urllib.request.urlopen(url, timeout=10) as resp:
return json.load(resp).get("transitions", [])
def to_dashy_entry(t):
rule_name = t["summary"].removeprefix("Falco rule fired - ")
status = t["new"]["status"]
when = datetime.datetime.fromtimestamp(t["when"], LOCAL_TZ).strftime("%-m/%-d %-I:%M%p").lower()
return {
"link": {
"text": f"{when} - {status} - {rule_name}",
"url": f"{NETDATA_URL}/alerts",
"title": f"{status}: {rule_name}",
},
"date": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t["when"])),
}
def main():
transitions = fetch_transitions()
raised = [
t
for t in transitions
if t.get("alert") == "falco_rule_match"
and t.get("new", {}).get("status") in ("WARNING", "CRITICAL")
]
raised.sort(key=lambda t: t["when"], reverse=True)
entries = [to_dashy_entry(t) for t in raised[:MAX_ENTRIES]]
with open(OUTPUT_PATH, "w") as f:
json.dump(entries, f, indent=2)
if __name__ == "__main__":
main()
+10
View File
@@ -10,6 +10,11 @@ services:
image: falcosecurity/falco:0.44.1 image: falcosecurity/falco:0.44.1
container_name: falco container_name: falco
restart: always restart: always
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
cap_drop: cap_drop:
- all - all
cap_add: cap_add:
@@ -32,3 +37,8 @@ services:
- ./rules/miner-detect.yaml:/etc/falco/rules.d/miner-detect.yaml:ro - ./rules/miner-detect.yaml:/etc/falco/rules.d/miner-detect.yaml:ro
- ./rules/miner-pool-ports.yaml:/etc/falco/rules.d/miner-pool-ports.yaml:ro - ./rules/miner-pool-ports.yaml:/etc/falco/rules.d/miner-pool-ports.yaml:ro
- ./rules/tune-noise.yaml:/etc/falco/rules.d/tune-noise.yaml:ro - ./rules/tune-noise.yaml:/etc/falco/rules.d/tune-noise.yaml:ro
- ./rules/git-hook-tamper.yaml:/etc/falco/rules.d/git-hook-tamper.yaml:ro
- ./rules/unexpected-child-of-git.yaml:/etc/falco/rules.d/unexpected-child-of-git.yaml:ro
- ./rules/ssh-persistence.yaml:/etc/falco/rules.d/ssh-persistence.yaml:ro
- ./rules/cloud-metadata-probe.yaml:/etc/falco/rules.d/cloud-metadata-probe.yaml:ro
- ./rules/db-spawned-process.yaml:/etc/falco/rules.d/db-spawned-process.yaml:ro
+47
View File
@@ -0,0 +1,47 @@
# Custom rule, adapted from falcosecurity/rules' Incubating ruleset (not
# shipped in this Falco image). This host has no cloud provider, so
# 169.254.169.254 (the AWS/GCP/Azure instance-metadata IP) has zero
# legitimate traffic ever - any connection attempt to it is a strong
# signal, either SSRF probing or a container image/script that assumes a
# cloud environment. Near-zero false-positive risk, so this stays a plain
# CRITICAL with no scoped exception - if something legitimate ever needs
# it, add a proc/container-scoped exception here rather than disabling.
#
# Deliberately does NOT use the stock "outbound" macro: that macro
# requires evt.rawres >= 0 (or EINPROGRESS), i.e. a connection that
# actually succeeded/is in progress. Since this host has no route to
# 169.254.169.254 at all, any attempt - malicious or a test - fails
# instantly at the kernel level (ENETUNREACH) and would never satisfy
# "outbound". The interesting signal here is the attempt itself, not
# whether it succeeded, so this matches the raw connect/send syscalls
# directly. Verified live: a wget to this address from a test container
# (which fails to connect, as expected with no route) still triggers this
# rule.
#
# One genuine false positive found during testing: netdata's own
# cloud-provider auto-detection runs `curl --fail -s -m 1 --noproxy *
# http://169.254.169.254` on every startup/reconnect, to check whether the
# host is running in AWS/GCP/Azure (standard monitoring-agent behavior,
# not a cloud provider assumption bug). Scoped to netdata's own curl
# specifically, not the whole container, since netdata making any OTHER
# connection to this address would still be worth knowing about.
- macro: known_cloud_metadata_probes
condition: (container.name = "netdata" and proc.name = curl)
- rule: Contact cloud metadata service from container
desc: >
Detects attempts to communicate with a cloud instance metadata service
(169.254.169.254) from a container. This host has no cloud provider,
so this endpoint should never see legitimate traffic - treat any hit
as SSRF probing or malware/scripts written for a cloud environment.
condition: >
evt.type in (connect, sendto, sendmsg)
and container
and fd.sip="169.254.169.254"
and not known_cloud_metadata_probes
output: >
Outbound connection to cloud instance metadata service
(user=%user.name command=%proc.cmdline connection=%fd.name
container=%container.name image=%container.image.repository pid=%proc.pid)
priority: CRITICAL
tags: [network, credential_access, mitre_credential_access]
+30
View File
@@ -0,0 +1,30 @@
# Custom rule, adapted from falcosecurity/rules' Incubating ruleset (not
# shipped in this Falco image). Generalizes the lesson from the gitea
# log-poisoning RCE (a service's own process forking something unexpected)
# to every other DB-backed service on this host - immich, firefly-iii,
# romm, inbox-zero-db, etc. A database server forking a child process other
# than itself is not normal and often follows a SQL injection attack.
- list: db_server_binaries
items: [mysqld, postgres, sqlplus]
- macro: user_known_db_spawned_processes
condition: (never_true)
- rule: DB program spawned process
desc: >
A program related to a database server created an unexpected child
process (other than itself). This is not supposed to happen and often
follows SQL injection attacks - could indicate unauthorized data
extraction or tampering.
condition: >
spawned_process
and proc.pname in (db_server_binaries)
and not proc.name in (db_server_binaries)
and not user_known_db_spawned_processes
output: >
Database-related program spawned unexpected process
(user=%user.name command=%proc.cmdline parent=%proc.pname
container=%container.name image=%container.image.repository pid=%proc.pid)
priority: WARNING
tags: [database, process, mitre_execution]
+40
View File
@@ -0,0 +1,40 @@
# Custom rule: catch execution of any git hook file gitea didn't put there.
# Added after the 2026-08-10/11 gitea internal-API log-poisoning attack, where
# an attacker used /api/internal/manager/add-logger to write a malicious
# uploadpack.packObjectsHook entry into the global .gitconfig, pointed at a
# planted hooks/pre-applypatch.sample file under an unrelated repo. Gitea only
# ever manages four hook names per repo (post-receive, pre-receive, update,
# proc-receive), each delegating to a same-named script under hooks/*.d/gitea -
# anything else executing from a hooks/ path is not something Gitea put there.
#
# BUG FOUND 2026-08-16: the original condition checked proc.name against
# gitea_managed_hook_names, but git's hook dispatcher always execs these as
# `bash ./hooks/<hookname>.d/gitea` - proc.name is "bash" (the interpreter),
# never "pre-receive"/"gitea"/etc. That check could never match, so this
# rule fired CRITICAL on every single git push to gitea - confirmed via
# docker logs falco showing 3 alerts (pre-receive/update/post-receive) on
# every push, including this repo's own commits. Fixed to check the actual
# invoked script path in proc.cmdline instead of proc.name.
- rule: Unexpected git hook execution
desc: >
A process executed from a path under a git repository's hooks/ directory
whose script isn't one of Gitea's own managed hook scripts. Catches
planted hooks used for persistence/RCE (e.g. a malicious
uploadpack.packObjectsHook or receive hook), independent of how the
file got written.
condition: >
spawned_process
and container.name = "gitea"
and proc.cmdline contains "/hooks/"
and not (proc.cmdline glob "*hooks/pre-receive.d/gitea"
or proc.cmdline glob "*hooks/update.d/gitea *"
or proc.cmdline glob "*hooks/post-receive.d/gitea"
or proc.cmdline glob "*hooks/proc-receive.d/gitea"
or proc.cmdline glob "*hooks/proc-receive.d/gitea *")
output: >
Unexpected git hook executed
(user=%user.name command=%proc.cmdline exepath=%proc.exepath
parent=%proc.pname container=%container.name pid=%proc.pid)
priority: CRITICAL
tags: [git, persistence, mitre_persistence]
+9
View File
@@ -15,11 +15,20 @@
# removed. # removed.
items: [3333, 3334, 4444, 5555, 5556, 7777, 8888, 9999, 14444] items: [3333, 3334, 4444, 5555, 5556, 7777, 8888, 9999, 14444]
# Loopback exclusion added 2026-08-25: some unidentified host-level process
# connects to ::1:8888 roughly once a day (confirmed 5x over 2026-08-20 to
# 2026-08-25, always ~21:3x, always container=host, always failing instantly
# since nothing listens on 8888 - falco loses process metadata for it before
# it can be identified). A real mining pool is by definition a remote
# server, so loopback destinations can never be genuine pool traffic - this
# is a false positive in the rule's design (any port 8888 connection, not
# just remote ones), not a tuned-out exception for a known process.
- rule: Detect outbound connection to common miner pool port - rule: Detect outbound connection to common miner pool port
desc: Outbound connection to a TCP port commonly used by cryptomining pools. desc: Outbound connection to a TCP port commonly used by cryptomining pools.
condition: > condition: >
outbound and evt.type in (connect, sendto, sendmsg) outbound and evt.type in (connect, sendto, sendmsg)
and fd.rport in (miner_pool_ports) and fd.rport in (miner_pool_ports)
and not (fd.rip in ("127.0.0.1", "::1"))
output: > output: >
Outbound connection to common miner-pool port Outbound connection to common miner-pool port
(user=%user.name command=%proc.cmdline connection=%fd.name (user=%user.name command=%proc.cmdline connection=%fd.name
+34
View File
@@ -0,0 +1,34 @@
# Custom rule, adapted from falcosecurity/rules' Incubating ruleset (not
# shipped in this Falco image - only the Stable ruleset ships by default).
# Added after research into what else might be useful post-incident: neither
# the gitea/xmrig nor gitea log-poisoning incidents involved SSH-key
# persistence, but it's the textbook next move after any RCE, and gitea
# itself has SSH access (port 222) making this directly relevant here.
- list: ssh_binaries
items: [
sshd, sftp-server, ssh-agent,
ssh, scp, sftp,
ssh-keygen, ssh-keysign, ssh-keyscan, ssh-add
]
- macro: user_ssh_directory
condition: (fd.name contains '/.ssh/' and fd.name glob '/home/*/.ssh/*')
- rule: Adding ssh keys to authorized_keys
desc: >
After gaining access, attackers can modify the authorized_keys file to
maintain persistence on a victim host. Detects any write to an
authorized_keys file under a user's .ssh directory or /root/.ssh,
by a process that isn't one of ssh's own binaries.
condition: >
open_write
and (user_ssh_directory or fd.name startswith /root/.ssh)
and fd.name endswith authorized_keys
and not proc.name in (ssh_binaries)
output: >
Adding ssh keys to authorized_keys
(user=%user.name file=%fd.name command=%proc.cmdline
container=%container.name image=%container.image.repository pid=%proc.pid)
priority: WARNING
tags: [ssh, persistence, mitre_persistence]
+66 -4
View File
@@ -9,13 +9,23 @@
# - pg_isready: fired every ~10s from inbox-zero-db's healthcheck touching # - pg_isready: fired every ~10s from inbox-zero-db's healthcheck touching
# /etc/shadow - NSS/libc user-lookup behavior during process init, not # /etc/shadow - NSS/libc user-lookup behavior during process init, not
# credential harvesting. # credential harvesting.
# - lscr.io/linuxserver/obsidian: fires once per container start from # - lscr.io/linuxserver/obsidian, lscr.io/linuxserver/calibre: fires once per
# `sed -i s/CORRUPT_FILE/NOPASSWD/g /etc/sudoers` - linuxserver.io's # container start from `sed -i s/CORRUPT_FILE/NOPASSWD/g /etc/sudoers` -
# own s6-init NOPASSWD setup, same class of init-time file touch. # linuxserver.io's own s6-init NOPASSWD setup, same class of init-time file
# touch. Same pattern confirmed on calibre 2026-08-19; scoped per-image
# rather than by proc.name alone since any other linuxserver.io image will
# hit this the first time it's added here too.
# - systemd-executor (container_name=host): fired 63x reading /etc/shadow and
# every /etc/pam.d/* file during the 2026-09-16 17:47 host reboot, as every
# service's login/session setup ran at once. Normal PAM stack behavior at
# boot, not credential harvesting - will recur on every future reboot.
# Scoped by proc.exepath since proc.name is a meaningless re-exec artifact
# ("9") for systemd's own executor process during --deserialize.
- macro: user_known_read_sensitive_files_activities - macro: user_known_read_sensitive_files_activities
condition: > condition: >
(proc.name = pg_isready) (proc.name = pg_isready)
or (container.image.repository = "lscr.io/linuxserver/obsidian" and proc.name = sed) or (container.image.repository in ("lscr.io/linuxserver/obsidian", "lscr.io/linuxserver/calibre") and proc.name = sed)
or (proc.exepath = "/usr/lib/systemd/systemd-executor")
# "Fileless execution via memfd_create" fires from nvidia-ctk's # "Fileless execution via memfd_create" fires from nvidia-ctk's
# ldconfig-refresh hook, which runs via memfd_create by design every time # ldconfig-refresh hook, which runs via memfd_create by design every time
@@ -49,3 +59,55 @@
(container.image.repository = "docker.gitea.com/gitea" and proc.name = gitea) (container.image.repository = "docker.gitea.com/gitea" and proc.name = gitea)
or (container.image.repository = "lscr.io/linuxserver/obsidian") or (container.image.repository = "lscr.io/linuxserver/obsidian")
or (container.image.repository = "tsl0922/ttyd" and proc.name in (telnet, pv, busybox-extras)) or (container.image.repository = "tsl0922/ttyd" and proc.name in (telnet, pv, busybox-extras))
# "Redirect STDOUT/STDIN to Network Connection in Container" false-positives,
# both confirmed recurring (not one-off) via netdata's alert history:
# - gitea: sshd/sshd-session redirect the just-accepted SSH connection's
# socket onto stdin/stdout/stderr via dup2 - this is simply how every SSH
# server handles every session, not a reverse shell. Fires 4x per SSH
# connection (sshd x2, sshd-session x2). Scoped to those two binaries in
# this one container, not the whole image, since gitea itself spawning
# this pattern from some other binary would still be worth flagging.
# - fireflyiii/core: its wait-for-it.sh startup script opens a raw TCP
# connection to the postgres container to poll for DB readiness, which
# redirects stdio the same way a reverse shell would mechanically. Scoped
# to the script's own cmdline, not the whole image.
# - rommapp/romm: its own init (bash /init) connects to its bundled loopback
# redis (127.0.0.1:6379) and app port (127.0.0.1:5000) at container start,
# same dup2-based mechanic. Confirmed at the 2026-09-16 host reboot; will
# recur on every container start. Scoped to the image, not proc.name,
# since this is the container's own /init script.
# - gitea web (not sshd this time): nginx-proxy's ordinary reverse-proxied
# HTTP traffic to gitea's own listen port hits the same dup3-onto-socket
# mechanic as the SSH case above, just for the web server instead of SSH.
# Confirmed 2026-09-25/26 firing every 5-15 min, always container=gitea,
# command="gitea web", user=git, fd.lport=3000 (gitea's fixed internal
# port, not internet-reachable directly - only nginx-proxy on
# npm-network can reach it) - i.e. every one of these traced to normal
# proxied web traffic volume, not a new pattern. Scoped to gitea's own
# listen port specifically, not the whole image or process, so a redirect
# on any *other* port from this container (still a real anomaly for a
# previously-compromised service) still gets flagged.
- macro: user_known_stand_streams_redirect_activities
condition: >
(container.image.repository = "docker.gitea.com/gitea" and proc.name in (sshd, sshd-session))
or (container.image.repository = "fireflyiii/core" and proc.cmdline contains "wait-for-it.sh")
or (container.image.repository = "rommapp/romm" and proc.cmdline = "bash /init")
or (container.image.repository = "docker.gitea.com/gitea" and proc.cmdline = "gitea web" and fd.lport = 3000)
# "Clear Log Activities" false-positive: lsyncd (host systemd service, not a
# container - running since 2026-07-29) truncates its own status file,
# /var/log/lsyncd-status.log, as normal self-logging behavior. Matched via
# the stock rule's fd.directory=/var/log check, not because it's actually
# clearing evidence. Scoped to that one file, not all of /var/log, so any
# other log-truncation in that directory still gets flagged.
- macro: allowed_clear_log_files
condition: (fd.name = "/var/log/lsyncd-status.log" and proc.name = lsyncd)
# "Run shell untrusted" false-positive: fireflyiii/core and
# fireflyiii/data-importer's own s6 healthcheck (`sh ./data/check`, spawned
# by s6-notifyoncheck) fires twice per container start. Confirmed at the
# 2026-09-16 host reboot; will recur on every start. This list is the
# rule's own designated customization point (default: empty).
- list: user_known_shell_spawn_binaries
items: [s6-notifyoncheck]
+28
View File
@@ -0,0 +1,28 @@
# Custom rule: catch git-upload-pack/git-receive-pack forking anything other
# than git's own pack-objects binary. Added after the 2026-08-10/11 gitea
# log-poisoning attack, which planted a malicious `uploadpack.packObjectsHook`
# global git config entry - a documented git RCE primitive where upload-pack
# forks an attacker-chosen executable instead of git-pack-objects on every
# fetch/clone. This is the moment the backdoor actually fires, independent of
# how the hook config got planted, so it's a strong last-line catch even if
# git-hook-tamper.yaml's file-write detection is bypassed some other way.
- list: git_pack_children
items: [git-pack-objects, git, git-remote-https, git-remote-http]
- rule: Unexpected child process of git pack service
desc: >
git-upload-pack or git-receive-pack spawned a process that isn't git's own
pack-objects binary. Normal fetch/push never does this - it's the exact
behavior of an abused uploadpack.packObjectsHook / pre-receive-style RCE.
condition: >
spawned_process
and container.name = "gitea"
and proc.pname in (git-upload-pack, git-receive-pack)
and not proc.name in (git_pack_children)
output: >
git pack service spawned unexpected child process
(user=%user.name command=%proc.cmdline parent=%proc.pname
container=%container.name pid=%proc.pid)
priority: CRITICAL
tags: [git, execution, mitre_execution]
+2
View File
@@ -41,6 +41,8 @@ services:
- AUTO_IMPORT_SECRET=${FIDI_AUTO_IMPORT_SECRET} - AUTO_IMPORT_SECRET=${FIDI_AUTO_IMPORT_SECRET}
volumes: volumes:
- /srv/firefly-iii/fidi-import:/import - /srv/firefly-iii/fidi-import:/import
ports:
- 8383:8080
networks: networks:
- default - default
- internal - internal
+5
View File
@@ -16,4 +16,9 @@ services:
- /srv/glances/glances.conf:/glances/conf/glances.conf - /srv/glances/glances.conf:/glances/conf/glances.conf
pid: host pid: host
runtime: nvidia runtime: nvidia
networks:
- npm-network
networks:
npm-network:
external: true
+38
View File
@@ -0,0 +1,38 @@
volumes:
nas_media:
external: true
name: nas_media
services:
jellyfin:
container_name: jellyfin
image: jellyfin/jellyfin
restart: unless-stopped
ports:
- 8096:8096/tcp
- 8920:8920/tcp
- 7359:7359/udp
environment:
- TZ=America/New_York
- PUID=1000
- PGID=1000
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
volumes:
- /srv/jellyfin/config:/config
- /srv/jellyfin/cache:/cache
- nas_media:/data
networks:
- default
- npm-network
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks:
default:
npm-network:
external: true
+27 -7
View File
@@ -78,38 +78,58 @@ services:
# CF_SLUG: "create-olympicjumperl" # CF_SLUG: "create-olympicjumperl"
# CF_MODPACK_ZIP: "/modpack.zip" # CF_MODPACK_ZIP: "/modpack.zip"
# SERVER_NAME: "Create SMP" # SERVER_NAME: "Create SMP"
# SEED: "-5296138078585483649" # SEED: "-1307549829135522678"
# MODE: "survival" # MODE: "survival"
# OPS: "Jimcognito,OlympicJumperL" # OPS: "Jimcognito,OlympicJumperL"
# ENFORCE_SECURE_PROFILE: "false" # ENFORCE_SECURE_PROFILE: "false"
# MEMORY: "4G" # MEMORY: "4G"
# volumes: # volumes:
# - /srv/minecraft-create:/data # - /srv/minecraft-create:/data
# - /mnt/nas_projects/Create-OlympicJumperL-2026-08-06.zip:/modpack.zip:ro # - /mnt/nas_projects/Create-OlympicJumperL-2026-08-20.zip:/modpack.zip:ro
# stdin_open: true # stdin_open: true
# tty: true # tty: true
# restart: unless-stopped # restart: unless-stopped
# mem_limit: 6g # mem_limit: 6g
third-life: survival:
image: itzg/minecraft-server:latest image: itzg/minecraft-server:latest
container_name: minecraft-third-life container_name: minecraft-survival
ports: ports:
- "55567:25565" - "55567:25565"
environment: environment:
EULA: "true" EULA: "true"
SERVER_NAME: "3rd Life" SERVER_NAME: "Survival SMP"
SEED: "-1102400853764765851"
MODE: "survival" MODE: "survival"
OPS: "Jimcognito,OlympicJumperL,MagicSpaceCat" OPS: "Jimcognito,OlympicJumperL"
ENFORCE_SECURE_PROFILE: "false" ENFORCE_SECURE_PROFILE: "false"
MEMORY: "4G" MEMORY: "4G"
volumes: volumes:
- /srv/minecraft-third-life:/data - /srv/minecraft-survival:/data
stdin_open: true stdin_open: true
tty: true tty: true
restart: unless-stopped restart: unless-stopped
mem_limit: 6g mem_limit: 6g
# third-life:
# image: itzg/minecraft-server:latest
# container_name: minecraft-third-life
# ports:
# - "55567:25565"
# environment:
# EULA: "true"
# SERVER_NAME: "3rd Life"
# MODE: "survival"
# OPS: "Jimcognito,OlympicJumperL,MagicSpaceCat"
# ENFORCE_SECURE_PROFILE: "false"
# MEMORY: "4G"
# volumes:
# - /srv/minecraft-third-life:/data
# stdin_open: true
# tty: true
# restart: unless-stopped
# mem_limit: 6g
# immersive: # immersive:
# image: itzg/minecraft-server:latest # image: itzg/minecraft-server:latest
# container_name: minecraft-immersive # container_name: minecraft-immersive
+2 -1
View File
@@ -1,10 +1,11 @@
services: services:
navidrome: navidrome:
image: deluan/navidrome:latest image: deluan/navidrome:0.64.0
container_name: navidrome container_name: navidrome
restart: unless-stopped restart: unless-stopped
environment: environment:
- ND_MUSICFOLDER=/media/music/albums - ND_MUSICFOLDER=/media/music/albums
- ND_AUTOIMPORTPLAYLISTS=false
- ND_LOGLEVEL=info - ND_LOGLEVEL=info
- ND_PORT=4533 - ND_PORT=4533
- TZ=America/New_York - TZ=America/New_York
+25
View File
@@ -0,0 +1,25 @@
# Silence the stock 10min_cpu_usage notification. Cryptominer detection is
# now handled directly and far more precisely by Falco (process/network
# behavior, not aggregate load) - see falco/rules/miner-detect.yaml and
# falco/rules/miner-pool-ports.yaml. This alarm was firing several times a
# day from Ollama's own legitimate sustained CPU use (subwave-controller
# driving chat completions), with no way to distinguish that from a real
# problem using system-wide CPU % alone. Kept evaluated (still visible on
# the netdata dashboard/API) but routed to the "silent" role, which has no
# configured recipients, so it no longer reaches Telegram or Netdata Cloud
# email - same idiom as the stock file's own comment recommends.
template: 10min_cpu_usage
on: system.cpu
class: Utilization
type: System
component: CPU
host labels: _os=linux
lookup: average -10m unaligned of user,system,softirq,irq,guest
units: %
every: 1m
warn: $this > (($status >= $WARNING) ? (75) : (85))
crit: $this > (($status == $CRITICAL) ? (85) : (95))
delay: down 15m multiplier 1.5 max 1h
summary: System CPU utilization
to: silent
+4 -10
View File
@@ -7,9 +7,10 @@ services:
- obico-internal - obico-internal
obico: obico:
# :cuda tag attempts GPU but falls back to CPU if CUDA version mismatch # :cuda tag falls back to CPU anyway on this GPU (libcudart.so.11.0
# (libcudart.so.11.0 required; GTX 1660 SUPER with driver 590 has CUDA 12). # required; GTX 1660 SUPER with driver 590 has CUDA 12) - adequate for a
# ML inference still works on CPU - adequate for a single printer. # single printer. No GPU reservation here so it doesn't reserve VRAM
# ollama needs for its own model (was forcing partial CPU offload there).
image: ghcr.io/imagegenius/obico:cuda image: ghcr.io/imagegenius/obico:cuda
container_name: obico container_name: obico
restart: unless-stopped restart: unless-stopped
@@ -33,13 +34,6 @@ services:
- /srv/obico/config/model_cache:/model_cache/ml_api - /srv/obico/config/model_cache:/model_cache/ml_api
ports: ports:
- "3334:3334" - "3334:3334"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
networks: networks:
- npm-network - npm-network
- obico-internal - obico-internal
+14
View File
@@ -10,6 +10,20 @@ services:
- NVIDIA_DRIVER_CAPABILITIES=compute,utility - NVIDIA_DRIVER_CAPABILITIES=compute,utility
- TZ=America/New_York - TZ=America/New_York
- OLLAMA_HOST=0.0.0.0 - OLLAMA_HOST=0.0.0.0
# This GPU (6GB) is right at the edge for qwen2.5:7b at 16384 context —
# KV cache size was observed varying 224MB-896MB between loads with flash
# attention off, and the larger figure tips offload to 25/29 layers
# instead of 29/29, causing multi-minute DJ-agent latency. Flash
# attention + an explicitly quantized KV cache keep it small and stable.
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_KV_CACHE_TYPE=q8_0
# Server-level default context, kept in step with SUB/WAVE's own
# llm.numCtx (settings.json) -- 11264 is the largest value that still
# fits the full 29-layer GPU offload on this 6GB card with margin, while
# staying above the ~7.5k-token peak seen in real multi-turn DJ-agent
# calls (a lower ceiling truncates the front of the prompt -- including
# the tool definitions -- and the agent stops calling `done`, #291).
- OLLAMA_CONTEXT_LENGTH=11264
volumes: volumes:
- /srv/ollama:/root/.ollama - /srv/ollama:/root/.ollama
runtime: nvidia runtime: nvidia
+57
View File
@@ -130,6 +130,61 @@ print(f'Done. ConfigHash: {hash}')
" "$response" " "$response"
} }
cmd_fix_git_auth() {
local name="${1:-}"
if [[ -z "$name" ]]; then
echo "Usage: $0 fix-git-auth <stack-name>" >&2
exit 1
fi
if [[ -z "${GITEA_USER:-}" || -z "${GITEA_TOKEN:-}" ]]; then
echo "Error: GITEA_USER/GITEA_TOKEN not set in .credentials" >&2
exit 1
fi
echo "Looking up stack '$name'..."
local stack_json
if ! stack_json=$(get_stack_json_by_name "$name"); then
echo "Error: stack '$name' not found" >&2
exit 1
fi
local stack_id
stack_id=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['Id'])" "$stack_json")
echo "Found stack ID: $stack_id"
# Re-supplies repository credentials that Portainer's stored GitConfig can
# lose (empty Authentication.Password) — the symptom is git/redeploy
# failing with "Unable to clone git repository directory".
local payload
payload=$(GITEA_USER="$GITEA_USER" GITEA_TOKEN="$GITEA_TOKEN" python3 -c "
import json, os, sys
stack = json.loads(sys.argv[1])
env = stack.get('Env') or []
print(json.dumps({
'pullImage': True,
'prune': False,
'env': env,
'repositoryAuthentication': True,
'repositoryUsername': os.environ['GITEA_USER'],
'repositoryPassword': os.environ['GITEA_TOKEN'],
}))
" "$stack_json")
echo "Re-authenticating and redeploying..."
local response
response=$(api_put "stacks/${stack_id}/git/redeploy?endpointId=${ENDPOINT_ID}" "$payload")
python3 -c "
import json, sys
d = json.loads(sys.argv[1])
if 'message' in d and 'Id' not in d:
print('Error:', d['message'])
sys.exit(1)
hash = d.get('GitConfig', {}).get('ConfigHash', 'unknown')[:10]
pw_set = bool(d.get('GitConfig', {}).get('Authentication', {}).get('Password'))
print(f'Done. ConfigHash: {hash}. Credential stored: {pw_set}')
" "$response"
}
cmd_deploy() { cmd_deploy() {
local name="${1:-}" local name="${1:-}"
local compose_path="${2:-}" local compose_path="${2:-}"
@@ -292,6 +347,7 @@ command="${1:-}"
case "$command" in case "$command" in
list) cmd_list ;; list) cmd_list ;;
redeploy) cmd_redeploy "${2:-}" ;; redeploy) cmd_redeploy "${2:-}" ;;
fix-git-auth) cmd_fix_git_auth "${2:-}" ;;
deploy) cmd_deploy "${@:2}" ;; deploy) cmd_deploy "${@:2}" ;;
get-env) cmd_get_env "${2:-}" ;; get-env) cmd_get_env "${2:-}" ;;
set-env) cmd_set_env "${2:-}" "${@:3}" ;; set-env) cmd_set_env "${2:-}" "${@:3}" ;;
@@ -304,6 +360,7 @@ case "$command" in
echo " deploy <stack-name> <path> [K=V ...] Create new git-linked stack with optional env vars" echo " deploy <stack-name> <path> [K=V ...] Create new git-linked stack with optional env vars"
echo " get-env <stack-name> Show env vars for a stack" echo " get-env <stack-name> Show env vars for a stack"
echo " set-env <stack-name> KEY=VAL [...] Set env vars (redeploys without new image pull)" echo " set-env <stack-name> KEY=VAL [...] Set env vars (redeploys without new image pull)"
echo " fix-git-auth <stack-name> Re-supply git credentials when redeploy fails with 'Unable to clone'"
exit 1 exit 1
;; ;;
esac esac
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Batch-drive qbt-relink-daily.sh over every single-file torrent matching a
# name pattern (e.g. all ~1268 individual Jeopardy episode torrents).
#
# Usage:
# ./qbt-relink-daily-batch.sh <name-substring> <show-root-folder> [concurrency]
# ./qbt-relink-daily-batch.sh jeopardy "/data/video/tv/Jeopardy! (1984)" 4
#
# Runs up to <concurrency> qbt-relink-daily.sh invocations in parallel
# (different hashes only - the underlying script's own no-repeat-same-hash
# rule is unaffected since each torrent here is distinct). Each torrent's
# full log goes to /tmp/qbt-relink-daily-batch/<hash>.log. Failures are
# logged and skipped, never retried automatically within the same run -
# re-run the whole batch command afterward to pick up anything that failed
# or was skipped (both qbt-relink-daily.sh and this driver are idempotent:
# already-relinked torrents are detected and skipped instantly).
set -euo pipefail
ENV_FILE="$HOME/.claude-homelab/.env"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="/tmp/qbt-relink-daily-batch"
mkdir -p "$LOG_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
NAME_SUBSTRING="${1:-}"
SHOW_ROOT="${2:-}"
CONCURRENCY="${3:-4}"
if [[ -z "$NAME_SUBSTRING" || -z "$SHOW_ROOT" ]]; then
echo "Usage: $0 <name-substring> <show-root-folder> [concurrency]" >&2
exit 1
fi
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
curl -s -c "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/auth/login" \
--data-urlencode "username=$QBITTORRENT_USERNAME" \
--data-urlencode "password=$QBITTORRENT_PASSWORD" > /dev/null
hashes=$(curl -s -b "$COOKIE_JAR" "$QBITTORRENT_URL/api/v2/torrents/info" | python3 -c "
import json, sys
d = json.load(sys.stdin)
needle = sys.argv[1].lower()
for t in d:
if needle in t['name'].lower():
print(t['hash'])
" "$NAME_SUBSTRING")
total=$(echo "$hashes" | grep -c . || true)
echo "Found $total torrent(s) matching '$NAME_SUBSTRING'. Running with concurrency $CONCURRENCY."
echo "Per-torrent logs: $LOG_DIR/<hash>.log"
echo
ok=0
failed=0
skipped_running=0
declare -a fail_list=()
run_one() {
local hash="$1"
"$SCRIPT_DIR/qbt-relink-daily.sh" "$hash" "$SHOW_ROOT" > "$LOG_DIR/$hash.log" 2>&1
}
i=0
running=0
declare -A pids=()
for hash in $hashes; do
i=$((i + 1))
run_one "$hash" &
pids["$hash"]=$!
running=$((running + 1))
if [[ "$running" -ge "$CONCURRENCY" ]]; then
wait -n
running=$((running - 1))
fi
done
echo "All $i job(s) launched, waiting for the rest to finish..."
wait
echo
echo "=== Summary ==="
for hash in "${!pids[@]}"; do
if grep -q "No data loss detected" "$LOG_DIR/$hash.log" 2>/dev/null || grep -q "Nothing to do" "$LOG_DIR/$hash.log" 2>/dev/null; then
ok=$((ok + 1))
else
failed=$((failed + 1))
fail_list+=("$hash")
fi
done
echo "OK: $ok / $total"
echo "Failed or incomplete: $failed"
if [[ "$failed" -gt 0 ]]; then
echo "Failed hashes (see $LOG_DIR/<hash>.log for detail):"
for h in "${fail_list[@]}"; do
echo " $h"
done
fi
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Re-point a SINGLE-FILE qBittorrent torrent (one episode of a daily/dated
# show, e.g. Jeopardy) at its Sonarr-organized destination file.
#
# qbt-relink.sh assumes the torrent's file count matches the destination
# FOLDER's file count 1:1 - true for season packs, false here: one torrent
# holds one episode, but the destination Season <year> folder holds every
# episode that aired that year. This script matches by date instead, parsed
# from the torrent's own original filename (YYYY.MM.DD or YYYY-MM-DD) against
# the single file in that season folder containing the same date.
#
# Usage:
# ./qbt-relink-daily.sh <torrent-hash> <show-root-folder>
# ./qbt-relink-daily.sh bd6c2778... "/data/video/tv/Jeopardy! (1984)"
#
# Same safety model as qbt-relink.sh: one blocking pass (stop -> locate ->
# rename -> recheck -> poll -> verify), pre/post-flight size check against
# the filesystem (not qBittorrent's self-reported state), idempotent, no
# repeated manual triggering against the same hash.
set -euo pipefail
ENV_FILE="$HOME/.claude-homelab/.env"
POLL_INTERVAL=15
POLL_TIMEOUT_S=1200 # 20 min - generous for a single file over NFS
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
if [[ -z "${QBITTORRENT_URL:-}" || -z "${QBITTORRENT_USERNAME:-}" || -z "${QBITTORRENT_PASSWORD:-}" ]]; then
echo "Error: QBITTORRENT_URL/USERNAME/PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
HASH="${1:-}"
SHOW_ROOT="${2:-}"
if [[ -z "$HASH" || -z "$SHOW_ROOT" ]]; then
echo "Usage: $0 <torrent-hash> <show-root-folder>" >&2
exit 1
fi
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
login_status=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/auth/login" \
--data-urlencode "username=$QBITTORRENT_USERNAME" \
--data-urlencode "password=$QBITTORRENT_PASSWORD")
if [[ "$login_status" != "200" ]]; then
echo "Error: qBittorrent login failed (http $login_status)" >&2
exit 1
fi
api_call() {
local label="$1" path="$2"
shift 2
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/$path" "$@")
if [[ "$status" != "200" ]]; then
echo "Error: $label failed (http $status)" >&2
exit 1
fi
}
torrent_info() {
curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/info" --data-urlencode "hashes=$HASH"
}
wait_for_path_field() {
local field="$1" expected="$2" match_mode="$3"
local attempt actual
for attempt in $(seq 1 15); do
actual=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['$field'])")
if [[ "$match_mode" == "prefix" && "$actual" == "$expected"* ]]; then
echo "$actual"; return 0
elif [[ "$match_mode" == "exact" && "$actual" == "$expected" ]]; then
echo "$actual"; return 0
fi
sleep 1
done
echo "$actual"
return 1
}
echo "=== Pre-flight ==="
files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
file_count=$(python3 -c "import json,sys; print(len(json.loads(sys.argv[1])))" "$files_json")
if [[ "$file_count" != "1" ]]; then
echo "Error: this script is for single-file torrents only (found $file_count files). Use qbt-relink.sh instead." >&2
exit 1
fi
old_name=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])[0]['name'])" "$files_json")
date_key=$(python3 -c "
import re, sys
m = re.search(r'(\d{4})[.\-](\d{2})[.\-](\d{2})', sys.argv[1])
if not m:
print('ERROR: could not parse a YYYY.MM.DD or YYYY-MM-DD date from the filename', file=sys.stderr)
sys.exit(1)
print(f'{m.group(1)}-{m.group(2)}-{m.group(3)}')
" "$old_name")
year="${date_key:0:4}"
season_folder="$SHOW_ROOT/Season $year"
if ! docker exec jellyfin sh -c "[ -d \"$season_folder\" ]"; then
echo "Error: season folder does not exist: $season_folder" >&2
exit 1
fi
matches=$(docker exec jellyfin sh -c "ls -1 \"$season_folder\"" | grep -F "$date_key" || true)
match_count=$(echo -n "$matches" | grep -c . || true)
if [[ "$match_count" -ne 1 ]]; then
echo "Error: expected exactly 1 file matching date $date_key in $season_folder, found $match_count:" >&2
echo "$matches" >&2
exit 1
fi
target_filename="$matches"
target_path="$season_folder/$target_filename"
target_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"")
echo "Torrent file: $old_name"
echo "Matched date $date_key -> $target_filename ($target_size bytes)"
# --- Idempotency check ---
current_info=$(torrent_info)
current_content_path=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['content_path'])")
if [[ "$current_content_path" == "$season_folder/$target_filename" ]]; then
current_progress=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['progress'])")
file_progress=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH" | python3 -c "import json,sys; print(json.load(sys.stdin)[0].get('progress',0))")
if python3 -c "exit(0 if float('$file_progress') >= 0.999 else 1)"; then
echo "Already relinked and verified (progress ~$current_progress). Nothing to do."
exit 0
fi
echo "content_path matches but not yet fully verified (progress: $current_progress) - proceeding."
fi
echo
echo "=== Stopping torrent ==="
api_call "stop" "torrents/stop" --data-urlencode "hashes=$HASH"
echo
echo "=== Applying ==="
echo "setLocation -> $season_folder"
api_call "setLocation" "torrents/setLocation" \
--data-urlencode "hashes=$HASH" \
--data-urlencode "location=$season_folder"
if ! actual_path=$(wait_for_path_field "save_path" "$season_folder" exact); then
echo "Error: setLocation did not take effect after 15s (save_path is still '$actual_path')." >&2
exit 1
fi
echo " confirmed: save_path is now $actual_path"
echo "setDownloadPath -> $season_folder (clears any stale incomplete-path override)"
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setDownloadPath" \
--data-urlencode "id=$HASH" --data-urlencode "path=$season_folder")
if [[ "$status" != "200" && "$status" != "400" ]]; then
echo "Error: setDownloadPath failed (http $status)" >&2
exit 1
fi
if ! content_path=$(wait_for_path_field "content_path" "$season_folder" prefix); then
echo "Error: content_path is '$content_path', not under '$season_folder' after 15s." >&2
exit 1
fi
echo " confirmed: content_path is now $content_path"
api_call "renameFile" "torrents/renameFile" \
--data-urlencode "hash=$HASH" \
--data-urlencode "oldPath=$old_name" \
--data-urlencode "newPath=$target_filename"
echo " renamed: $old_name -> $target_filename"
echo
echo "=== Rechecking (triggered exactly once) ==="
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
elapsed=0
final_state=""
while [[ "$elapsed" -lt "$POLL_TIMEOUT_S" ]]; do
sleep "$POLL_INTERVAL"
elapsed=$((elapsed + POLL_INTERVAL))
state=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['state'])")
if [[ "$state" != "checkingUP" && "$state" != "checkingDL" && "$state" != "checkingResumeData" ]]; then
final_state="$state"
break
fi
done
if [[ -z "$final_state" ]]; then
echo "TIMED OUT after ${POLL_TIMEOUT_S}s. Do not re-trigger by hand - re-run this exact invocation later (idempotent)." >&2
exit 1
fi
echo " settled: $final_state"
echo
echo "=== Post-check ==="
final_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"" 2>/dev/null || echo "MISSING")
if [[ "$final_size" != "$target_size" ]]; then
echo "CRITICAL: $target_path size changed or vanished ($target_size -> $final_size). Torrent left STOPPED. Investigate by hand." >&2
exit 1
fi
echo "File intact ($final_size bytes matches pre-flight). No data loss detected."
echo "Done. Torrent left STOPPED - start it from the WebUI once satisfied."
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Re-point a SINGLE-FILE qBittorrent torrent holding one standard SxxEyy
# episode (not a date-based daily show - see qbt-relink-daily.sh for that)
# at its Sonarr-organized destination file.
#
# Same shape problem as qbt-relink-daily.sh: qbt-relink.sh assumes the
# torrent's file count matches the destination FOLDER's file count 1:1,
# true for season packs, false here - one torrent holds one episode, but
# the destination Season NN folder holds every episode in that season.
# Matches by SxxEyy parsed from the torrent's own original filename against
# the single file with that season/episode in the destination Season NN
# folder.
#
# Usage:
# ./qbt-relink-episode.sh <torrent-hash> <show-root-folder>
# ./qbt-relink-episode.sh ce7c0f5e... "/data/video/tv/Star Trek - Lower Decks (2020)"
#
# Same safety model as qbt-relink.sh/qbt-relink-daily.sh: one blocking pass,
# pre/post-flight filesystem size check (not qBittorrent's self-reported
# state), idempotent, no repeated manual triggering against the same hash.
set -euo pipefail
ENV_FILE="$HOME/.claude-homelab/.env"
POLL_INTERVAL=15
POLL_TIMEOUT_S=1200
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
if [[ -z "${QBITTORRENT_URL:-}" || -z "${QBITTORRENT_USERNAME:-}" || -z "${QBITTORRENT_PASSWORD:-}" ]]; then
echo "Error: QBITTORRENT_URL/USERNAME/PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
HASH="${1:-}"
SHOW_ROOT="${2:-}"
if [[ -z "$HASH" || -z "$SHOW_ROOT" ]]; then
echo "Usage: $0 <torrent-hash> <show-root-folder>" >&2
exit 1
fi
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
login_status=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/auth/login" \
--data-urlencode "username=$QBITTORRENT_USERNAME" \
--data-urlencode "password=$QBITTORRENT_PASSWORD")
if [[ "$login_status" != "200" ]]; then
echo "Error: qBittorrent login failed (http $login_status)" >&2
exit 1
fi
api_call() {
local label="$1" path="$2"
shift 2
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/$path" "$@")
if [[ "$status" != "200" ]]; then
echo "Error: $label failed (http $status)" >&2
exit 1
fi
}
torrent_info() {
curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/info" --data-urlencode "hashes=$HASH"
}
wait_for_path_field() {
local field="$1" expected="$2" match_mode="$3"
local attempt actual
for attempt in $(seq 1 15); do
actual=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['$field'])")
if [[ "$match_mode" == "prefix" && "$actual" == "$expected"* ]]; then
echo "$actual"; return 0
elif [[ "$match_mode" == "exact" && "$actual" == "$expected" ]]; then
echo "$actual"; return 0
fi
sleep 1
done
echo "$actual"
return 1
}
echo "=== Pre-flight ==="
files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
file_count=$(python3 -c "import json,sys; print(len(json.loads(sys.argv[1])))" "$files_json")
if [[ "$file_count" != "1" ]]; then
echo "Error: this script is for single-file torrents only (found $file_count files). Use qbt-relink.sh instead." >&2
exit 1
fi
old_name=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])[0]['name'])" "$files_json")
ep_key=$(python3 -c "
import re, sys
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', sys.argv[1])
if not m:
print('ERROR: could not parse SxxEyy from the filename', file=sys.stderr)
sys.exit(1)
print(f'{int(m.group(1)):02d} {int(m.group(2)):02d}')
" "$old_name")
season_num="${ep_key%% *}"
ep_num="${ep_key##* }"
season_folder="$SHOW_ROOT/Season $season_num"
if ! docker exec jellyfin sh -c "[ -d \"$season_folder\" ]"; then
echo "Error: season folder does not exist: $season_folder" >&2
exit 1
fi
matches=$(docker exec jellyfin sh -c "ls -1 \"$season_folder\"" | python3 -c "
import re, sys
target_s, target_e = int('$season_num'), int('$ep_num')
for line in sys.stdin:
line = line.rstrip()
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', line)
if m and int(m.group(1)) == target_s and int(m.group(2)) == target_e:
print(line)
")
match_count=$(echo -n "$matches" | grep -c . || true)
if [[ "$match_count" -ne 1 ]]; then
echo "Error: expected exactly 1 file matching S${season_num}E${ep_num} in $season_folder, found $match_count:" >&2
echo "$matches" >&2
exit 1
fi
target_filename="$matches"
target_path="$season_folder/$target_filename"
target_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"")
echo "Torrent file: $old_name"
echo "Matched S${season_num}E${ep_num} -> $target_filename ($target_size bytes)"
# --- Idempotency check ---
current_info=$(torrent_info)
current_content_path=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['content_path'])")
if [[ "$current_content_path" == "$season_folder/$target_filename" ]]; then
current_progress=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['progress'])")
file_progress=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH" | python3 -c "import json,sys; print(json.load(sys.stdin)[0].get('progress',0))")
if python3 -c "exit(0 if float('$file_progress') >= 0.999 else 1)"; then
echo "Already relinked and verified (progress ~$current_progress). Nothing to do."
exit 0
fi
echo "content_path matches but not yet fully verified (progress: $current_progress) - proceeding."
fi
echo
echo "=== Stopping torrent ==="
api_call "stop" "torrents/stop" --data-urlencode "hashes=$HASH"
echo
echo "=== Applying ==="
echo "setLocation -> $season_folder"
api_call "setLocation" "torrents/setLocation" \
--data-urlencode "hashes=$HASH" \
--data-urlencode "location=$season_folder"
if ! actual_path=$(wait_for_path_field "save_path" "$season_folder" exact); then
echo "Error: setLocation did not take effect after 15s (save_path is still '$actual_path')." >&2
exit 1
fi
echo " confirmed: save_path is now $actual_path"
echo "setDownloadPath -> $season_folder (clears any stale incomplete-path override)"
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setDownloadPath" \
--data-urlencode "id=$HASH" --data-urlencode "path=$season_folder")
if [[ "$status" != "200" && "$status" != "400" ]]; then
echo "Error: setDownloadPath failed (http $status)" >&2
exit 1
fi
if ! content_path=$(wait_for_path_field "content_path" "$season_folder" prefix); then
echo "Error: content_path is '$content_path', not under '$season_folder' after 15s." >&2
exit 1
fi
echo " confirmed: content_path is now $content_path"
api_call "renameFile" "torrents/renameFile" \
--data-urlencode "hash=$HASH" \
--data-urlencode "oldPath=$old_name" \
--data-urlencode "newPath=$target_filename"
echo " renamed: $old_name -> $target_filename"
echo
echo "=== Rechecking (triggered exactly once) ==="
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
elapsed=0
final_state=""
while [[ "$elapsed" -lt "$POLL_TIMEOUT_S" ]]; do
sleep "$POLL_INTERVAL"
elapsed=$((elapsed + POLL_INTERVAL))
state=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['state'])")
if [[ "$state" != "checkingUP" && "$state" != "checkingDL" && "$state" != "checkingResumeData" ]]; then
final_state="$state"
break
fi
done
if [[ -z "$final_state" ]]; then
echo "TIMED OUT after ${POLL_TIMEOUT_S}s. Do not re-trigger by hand - re-run this exact invocation later (idempotent)." >&2
exit 1
fi
echo " settled: $final_state"
echo
echo "=== Post-check ==="
final_size=$(docker exec jellyfin sh -c "stat -c%s \"$target_path\"" 2>/dev/null || echo "MISSING")
if [[ "$final_size" != "$target_size" ]]; then
echo "CRITICAL: $target_path size changed or vanished ($target_size -> $final_size). Torrent left STOPPED. Investigate by hand." >&2
exit 1
fi
echo "File intact ($final_size bytes matches pre-flight). No data loss detected."
echo "Done. Torrent left STOPPED - start it from the WebUI once satisfied."
Executable
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
# Re-point a qBittorrent torrent at files Sonarr has since renamed/moved.
#
# When Sonarr imports an existing download via hardlink, it removes the file
# from its original torrent-named folder (only the new hardlinked copy
# remains) - so qBittorrent's record of that torrent silently starts
# pointing at nothing. This re-links the torrent to the new location so it
# keeps seeding the same underlying data (same inode, so the hash check
# passes) instead of erroring out as "missing files".
#
# Usage:
# ./qbt-relink.sh <torrent-hash> <new-folder>
#
# <new-folder> should be the Sonarr season folder (or show folder, for a
# single-season show) containing the renamed files - e.g.
# ./qbt-relink.sh 8601b9b9... "/data/video/tv/Andor (2022)/Season 02"
#
# Pairs the torrent's files with the files in <new-folder> by SxxEyy episode
# number parsed out of each filename (falls back to alphabetical sort only if
# a name can't be parsed, with a loud warning - alphabetical sort silently
# mismatches on non-zero-padded episode numbers, e.g. "E9" sorts after "E10").
# Always prints the proposed pairing before applying - check it makes sense,
# especially for anything with specials/extras where a torrent's files span
# more than one destination folder (this script only handles one folder;
# see the sonarr skill doc for that case).
#
# SAFETY MODEL (rewritten after a real data-loss incident - see the sonarr
# skill doc, "Incident 3"):
#
# - The whole operation runs as ONE blocking pass: stop -> relocate -> rename
# -> recheck -> poll to completion -> verify -> report. It does NOT return
# control with "poll manually and come back" - that pattern is exactly what
# caused the incident, because re-running recheck/stop by hand later, after
# qBittorrent's own state had moved on, raced against qBittorrent's
# automatic incomplete-file management and let it destroy real files.
# NEVER call recheck/stop/setLocation against the same hash a second time
# while a previous invocation might still be settling - wait for this
# script to finish (or clearly fail) first.
# - Before touching qBittorrent at all, it snapshots the destination folder's
# filenames+sizes (the ground truth - Sonarr already put the real files
# there). After the recheck settles, it re-reads the folder and compares.
# Any file that shrank, vanished, or changed size is treated as data loss,
# reported loudly, and the script does NOT attempt any further remediation
# (no retry, no re-trigger) - investigate by hand from a known-bad state
# rather than risk compounding it.
# - If the torrent already looks correctly relinked (content_path matches and
# on-disk files match the expected sizes), the script does nothing further
# - idempotent, so re-running it after a partial failure is safe rather
# than repeating destructive steps.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$HOME/.claude-homelab/.env"
POLL_INTERVAL=15
POLL_TIMEOUT_S=2400 # 40 min - generous for large multi-GB files over NFS
if [[ ! -f "$ENV_FILE" ]]; then
echo "Error: $ENV_FILE not found (qBittorrent creds live there, not this repo's .credentials)" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
if [[ -z "${QBITTORRENT_URL:-}" || -z "${QBITTORRENT_USERNAME:-}" || -z "${QBITTORRENT_PASSWORD:-}" ]]; then
echo "Error: QBITTORRENT_URL/USERNAME/PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
HASH="${1:-}"
NEW_FOLDER="${2:-}"
if [[ -z "$HASH" || -z "$NEW_FOLDER" ]]; then
echo "Usage: $0 <torrent-hash> <new-folder>" >&2
exit 1
fi
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
login_status=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/auth/login" \
--data-urlencode "username=$QBITTORRENT_USERNAME" \
--data-urlencode "password=$QBITTORRENT_PASSWORD")
if [[ "$login_status" != "200" ]]; then
echo "Error: qBittorrent login failed (http $login_status)" >&2
exit 1
fi
# api_call <label> <path> [curl data args...]
api_call() {
local label="$1" path="$2"
shift 2
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/$path" "$@")
if [[ "$status" != "200" ]]; then
echo "Error: $label failed (http $status)" >&2
exit 1
fi
}
torrent_info() {
curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/info" --data-urlencode "hashes=$HASH"
}
# manifest_of <folder> -> "name\tsize" lines, sorted by name
# Uses printf, not `stat -c '%n\t%s'` - stat's own \t escape handling isn't
# reliable across environments and silently produced a literal backslash-t
# instead of a real tab the first time this was tried, breaking `cut -f1`.
manifest_of() {
docker exec jellyfin sh -c "cd \"$1\" && for f in *; do [ -f \"\$f\" ] && printf '%s\t%s\n' \"\$f\" \"\$(stat -c%s \"\$f\")\"; done" 2>/dev/null | sort
}
# wait_for_field <jq-ish python expr on the info dict> <expected-value> <label>
# setLocation/setDownloadPath are asynchronous - qBittorrent returns 200
# before the internal move actually completes, so checking immediately can
# see stale data. Poll briefly instead of a single immediate check.
wait_for_path_field() {
local field="$1" expected="$2" match_mode="$3" label="$4"
local attempt actual
for attempt in $(seq 1 15); do
actual=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['$field'])")
if [[ "$match_mode" == "prefix" && "$actual" == "$expected"* ]]; then
echo "$actual"
return 0
elif [[ "$match_mode" == "exact" && "$actual" == "$expected" ]]; then
echo "$actual"
return 0
fi
sleep 1
done
echo "$actual"
return 1
}
echo "=== Pre-flight ==="
if ! docker exec jellyfin sh -c "[ -d \"$NEW_FOLDER\" ]"; then
echo "Error: destination folder does not exist: $NEW_FOLDER" >&2
exit 1
fi
baseline_manifest=$(manifest_of "$NEW_FOLDER")
baseline_count=$(echo "$baseline_manifest" | grep -c . || true)
if [[ "$baseline_count" -eq 0 ]]; then
echo "Error: destination folder is empty: $NEW_FOLDER" >&2
exit 1
fi
echo "Destination has $baseline_count file(s) - recorded as ground truth."
# --- Idempotency check: is this already correctly relinked? ---
BASELINE_MANIFEST_FILE=$(mktemp)
echo "$baseline_manifest" > "$BASELINE_MANIFEST_FILE"
trap 'rm -f "$COOKIE_JAR" "$BASELINE_MANIFEST_FILE"' EXIT
current_info=$(torrent_info)
current_content_path=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['content_path'])")
if [[ "$current_content_path" == "$NEW_FOLDER"* ]]; then
echo "content_path already points at $NEW_FOLDER - checking if fully verified..."
current_progress=$(echo "$current_info" | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['progress'])")
# Compare current qBittorrent-tracked file NAMES (not just count) and
# progress to the manifest. Progress alone is not enough to trust - it
# can be stale, cached from before a move, if a prior attempt was
# interrupted before ever triggering a fresh recheck at the new
# location (this happened for real: a torrent showed progress 1 for
# files under a subfolder that had already stopped existing on disk).
# Requiring the basename to appear in the manifest means renameFile
# must have actually already run - a stale-but-unrenamed torrent can no
# longer produce a false "already good".
already_good=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH" | python3 -c "
import json, os, sys
files = json.load(sys.stdin)
manifest = {}
with open(sys.argv[1]) as f:
for line in f:
line = line.rstrip(chr(10))
if not line: continue
name, size = line.rsplit(chr(9), 1)
manifest[name] = int(size)
if len(files) != len(manifest):
print('no')
else:
names_match = all(os.path.basename(f['name']) in manifest for f in files)
progress_ok = all(f.get('progress', 0) >= 0.999 for f in files)
print('yes' if (names_match and progress_ok) else 'no')
" "$BASELINE_MANIFEST_FILE" 2>/dev/null || echo "no")
if [[ "$already_good" == "yes" ]]; then
echo "Already relinked and verified (progress ~$current_progress). Nothing to do."
exit 0
fi
echo "Not yet fully verified (progress: $current_progress) - proceeding carefully."
fi
# --- Stop the torrent before touching anything ---
echo
echo "=== Stopping torrent ==="
api_call "stop" "torrents/stop" --data-urlencode "hashes=$HASH"
old_files_json=$(curl -s -b "$COOKIE_JAR" -G "$QBITTORRENT_URL/api/v2/torrents/files" --data-urlencode "hash=$HASH")
torrent_name=$(python3 -c "
import json,sys
files = json.loads(sys.argv[1])
if not files:
print('ERROR: no files returned for this hash - check the hash is correct', file=sys.stderr)
sys.exit(1)
print(files[0]['name'].split('/')[0])
" "$old_files_json")
echo "Torrent folder: $torrent_name"
echo "New folder: $NEW_FOLDER"
echo
new_files=$(echo "$baseline_manifest" | cut -f1)
echo "=== Computing file pairing ==="
python3 -c "
import json, re, sys
def ep_key(name):
m = re.search(r'[Ss](\d{1,4})[Ee](\d{1,4})', name)
return (int(m.group(1)), int(m.group(2))) if m else None
old_files = json.loads(sys.argv[1])
new_files = sys.argv[2].strip().split(chr(10)) if sys.argv[2].strip() else []
if len(old_files) != len(new_files):
print(f'ERROR: file count mismatch - torrent has {len(old_files)} files, destination has {len(new_files)}', file=sys.stderr)
sys.exit(1)
old_keys = {f['name']: ep_key(f['name']) for f in old_files}
new_keys = {n: ep_key(n) for n in new_files}
if all(old_keys.values()) and all(new_keys.values()) and len(set(old_keys.values())) == len(old_files) and len(set(new_keys.values())) == len(new_files):
old_by_key = {v: k for k, v in old_keys.items()}
new_by_key = {v: k for k, v in new_keys.items()}
common = set(old_by_key) & set(new_by_key)
if common != set(old_by_key) or common != set(new_by_key):
print('ERROR: episode-number sets differ between torrent and destination - not safe to auto-pair. Check both manually.', file=sys.stderr)
sys.exit(1)
pairs = [(old_by_key[k], new_by_key[k]) for k in sorted(common)]
else:
print('WARNING: could not parse a unique SxxEyy from every filename - falling back to alphabetical-sort pairing.', file=sys.stderr)
print(' Double-check the pairing below carefully before it applies.', file=sys.stderr)
pairs = list(zip(sorted(old_keys), sorted(new_keys)))
with open('/tmp/qbt_relink_flat.tsv', 'w') as f:
for old, new in pairs:
print(f' {old}')
print(f' -> {new}')
f.write(f'{old}\t{new}\n')
" "$old_files_json" "$new_files"
echo
echo "=== Applying (single pass - stop/relocate/rename/recheck, no retries) ==="
echo "setLocation -> $NEW_FOLDER"
api_call "setLocation" "torrents/setLocation" \
--data-urlencode "hashes=$HASH" \
--data-urlencode "location=$NEW_FOLDER"
if ! actual_path=$(wait_for_path_field "save_path" "$NEW_FOLDER" exact "save_path"); then
echo "Error: setLocation did not take effect after 15s (save_path is still '$actual_path'). Torrent is stopped - not proceeding." >&2
exit 1
fi
echo " confirmed: save_path is now $actual_path"
echo "setDownloadPath -> $NEW_FOLDER (clears any stale incomplete-path override)"
status=$(curl -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" -X POST "$QBITTORRENT_URL/api/v2/torrents/setDownloadPath" \
--data-urlencode "id=$HASH" --data-urlencode "path=$NEW_FOLDER")
if [[ "$status" != "200" && "$status" != "400" ]]; then
echo "Error: setDownloadPath failed (http $status)" >&2
exit 1
fi
if ! content_path=$(wait_for_path_field "content_path" "$NEW_FOLDER" prefix "content_path"); then
echo "Error: content_path is '$content_path', not under '$NEW_FOLDER' after 15s. Not proceeding." >&2
exit 1
fi
echo " confirmed: content_path is now $content_path"
while IFS=$'\t' read -r old new; do
api_call "renameFile" "torrents/renameFile" \
--data-urlencode "hash=$HASH" \
--data-urlencode "oldPath=$old" \
--data-urlencode "newPath=$new"
echo " renamed: $old -> $new"
done < /tmp/qbt_relink_flat.tsv
echo
echo "=== Rechecking (recheck triggered exactly once) ==="
api_call "recheck" "torrents/recheck" --data-urlencode "hashes=$HASH"
elapsed=0
final_state=""
while [[ "$elapsed" -lt "$POLL_TIMEOUT_S" ]]; do
sleep "$POLL_INTERVAL"
elapsed=$((elapsed + POLL_INTERVAL))
state=$(torrent_info | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['state'])")
if [[ "$state" != "checkingUP" && "$state" != "checkingDL" && "$state" != "checkingResumeData" ]]; then
final_state="$state"
break
fi
echo " still checking (${elapsed}s elapsed): $state"
done
if [[ -z "$final_state" ]]; then
echo
echo "TIMED OUT after ${POLL_TIMEOUT_S}s still checking. DO NOT re-trigger recheck or resume by hand -" >&2
echo "that repeated-intervention pattern is what caused the data-loss incident. Instead re-run this" >&2
echo "exact script invocation again later (it's idempotent) or investigate read-only first." >&2
exit 1
fi
echo " settled: $final_state"
echo
echo "=== Post-check: verifying files against the pre-flight manifest ==="
FINAL_MANIFEST_FILE=$(mktemp)
manifest_of "$NEW_FOLDER" > "$FINAL_MANIFEST_FILE"
trap 'rm -f "$COOKIE_JAR" "$BASELINE_MANIFEST_FILE" "$FINAL_MANIFEST_FILE"' EXIT
mismatch=$(python3 -c "
import sys
def load(path):
d = {}
with open(path) as f:
for line in f:
line = line.rstrip(chr(10))
if not line:
continue
name, size = line.rsplit(chr(9), 1)
d[name] = size
return d
baseline = load(sys.argv[1])
final = load(sys.argv[2])
problems = []
for name, size in baseline.items():
if name not in final:
problems.append(f'MISSING: {name}')
elif final[name] != size:
problems.append(f'SIZE CHANGED: {name} ({size} -> {final[name]})')
for name in final:
if name not in baseline:
problems.append(f'UNEXPECTED NEW FILE: {name}')
print(chr(10).join(problems))
" "$BASELINE_MANIFEST_FILE" "$FINAL_MANIFEST_FILE")
if [[ -n "$mismatch" ]]; then
echo "CRITICAL: files at $NEW_FOLDER changed during relink - possible data loss:" >&2
echo "$mismatch" >&2
echo >&2
echo "The torrent is left STOPPED. Do NOT re-run this script or touch qBittorrent further" >&2
echo "until this is investigated by hand - re-running blind is what caused the original incident." >&2
exit 1
fi
echo "All $baseline_count file(s) match the pre-flight manifest - no data loss detected."
echo
echo "Done. Torrent is left STOPPED on purpose. Verify state before starting it:"
echo " bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info $HASH"
echo "Then start it from the qBittorrent WebUI once you're satisfied."
Executable
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
# Sonarr management script - focused on the library-migration workflow
# (search/add existing shows, scan a raw folder, and manually import it into
# the Show Name (Year)/Season NN/... layout Jellyfin/Plex expect).
#
# Usage:
# ./sonarr.sh lookup <term>
# ./sonarr.sh add <tvdbId> [rootFolderPath]
# ./sonarr.sh list
# ./sonarr.sh rootfolders
# ./sonarr.sh scan <folder>
# ./sonarr.sh import <folder>
# ./sonarr.sh queue
# ./sonarr.sh test-client
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CREDENTIALS_FILE="$SCRIPT_DIR/.credentials"
if [[ ! -f "$CREDENTIALS_FILE" ]]; then
echo "Error: credentials file not found at $CREDENTIALS_FILE" >&2
exit 1
fi
# shellcheck source=.credentials
source "$CREDENTIALS_FILE"
API="$SONARR_URL/api/v3"
AUTH_HEADER="X-Api-Key: $SONARR_API_KEY"
DEFAULT_ROOT="/data/video/tv"
DEFAULT_QUALITY_PROFILE_ID=1
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
api_get() {
curl -s -H "$AUTH_HEADER" "$API/$1"
}
api_get_query() {
local path="$1"
shift
curl -s -G -H "$AUTH_HEADER" "$API/$path" "$@"
}
# Payload goes through a temp file (curl -d @file), not as a command-line
# argument - a large library (e.g. Jeopardy's 1269-file import payload) can
# exceed the OS argument-length limit and fail with "Argument list too long"
# if passed directly via -d "$2".
api_post() {
local payload_file
payload_file=$(mktemp)
printf '%s' "$2" > "$payload_file"
curl -s -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \
-d "@$payload_file" "$API/$1"
rm -f "$payload_file"
}
api_put() {
local payload_file
payload_file=$(mktemp)
printf '%s' "$2" > "$payload_file"
curl -s -X PUT -H "$AUTH_HEADER" -H "Content-Type: application/json" \
-d "@$payload_file" "$API/$1"
rm -f "$payload_file"
}
# --------------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------------
cmd_lookup() {
local term="${1:-}"
if [[ -z "$term" ]]; then
echo "Usage: $0 lookup <term>" >&2
exit 1
fi
api_get_query "series/lookup" --data-urlencode "term=$term" | python3 -c "
import json, sys
results = json.load(sys.stdin)
if not results:
print('No matches found.')
sys.exit(0)
for s in results[:10]:
existing = ' [already in Sonarr]' if s.get('id') else ''
print(f\"tvdb:{s.get('tvdbId')}\t{s['title']} ({s.get('year')})\t{s.get('status')}{existing}\")
"
}
cmd_add() {
local tvdb_id="${1:-}"
local root="${2:-$DEFAULT_ROOT}"
if [[ -z "$tvdb_id" ]]; then
echo "Usage: $0 add <tvdbId> [rootFolderPath]" >&2
exit 1
fi
tvdb_id="${tvdb_id#tvdb:}" # accept either "411959" or "tvdb:411959" (lookup's output format)
local lookup_json
lookup_json=$(api_get_query "series/lookup" --data-urlencode "term=tvdb:$tvdb_id")
local payload
payload=$(python3 -c "
import json, sys
results = json.loads(sys.argv[1])
if not results:
print('Error: no series found for that TVDB id', file=sys.stderr)
sys.exit(1)
s = results[0]
s['rootFolderPath'] = sys.argv[2]
s['qualityProfileId'] = $DEFAULT_QUALITY_PROFILE_ID
s['seasonFolder'] = True
s['monitored'] = False
s['addOptions'] = {
'monitor': 'none',
'searchForMissingEpisodes': False,
'searchForCutoffUnmetEpisodes': False,
}
print(json.dumps(s))
" "$lookup_json" "$root")
local response
response=$(api_post "series" "$payload")
python3 -c "
import json, sys
d = json.loads(sys.argv[1])
if 'message' in d and 'id' not in d:
print('Error:', d.get('message'))
sys.exit(1)
print(f\"Added: {d['title']} ({d['year']}) -> {d['path']} [id={d['id']}]\")
" "$response"
}
cmd_list() {
api_get "series" | python3 -c "
import json, sys
for s in sorted(json.load(sys.stdin), key=lambda x: x['sortTitle']):
print(f\"{s['id']}\t{s['title']} ({s.get('year')})\t{s['path']}\")
"
}
cmd_rootfolders() {
api_get "rootfolder" | python3 -c "
import json, sys
for r in json.load(sys.stdin):
print(f\"{r['path']}\tfree: {r['freeSpace']/1e9:.1f} GB\tunmapped folders: {len(r.get('unmappedFolders', []))}\")
"
}
cmd_monitor() {
local series_id="${1:-}"
local do_search="${2:-}"
if [[ -z "$series_id" ]]; then
echo "Usage: $0 monitor <seriesId> [--search]" >&2
echo " Flips a show from unmonitored (migration-only) to monitored - Sonarr will" >&2
echo " pick up new episodes via the tv-sonarr qBittorrent category going forward." >&2
echo " --search also triggers an immediate search for any missing episodes." >&2
exit 1
fi
local series_json
series_json=$(api_get "series/$series_id")
local not_found
not_found=$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); print('yes' if 'title' not in d else 'no')" "$series_json")
if [[ "$not_found" == "yes" ]]; then
echo "Error: no series with id $series_id (see: ./sonarr.sh list)" >&2
exit 1
fi
local payload
payload=$(python3 -c "
import json, sys
s = json.loads(sys.argv[1])
s['monitored'] = True
# Also monitor all seasons - a series-level monitored flag alone won't pull
# individual season/episode monitoring along with it.
for season in s.get('seasons', []):
season['monitored'] = True
print(json.dumps(s))
" "$series_json")
local response
response=$(api_put "series/$series_id" "$payload")
python3 -c "
import json, sys
d = json.loads(sys.argv[1])
if 'message' in d and 'id' not in d:
print('Error:', d.get('message'))
sys.exit(1)
print(f\"Monitoring: {d['title']} ({d['year']}) - new episodes will auto-import via the tv-sonarr download client category.\")
" "$response"
if [[ "$do_search" == "--search" ]]; then
api_post "command" "{\"name\": \"SeriesSearch\", \"seriesId\": $series_id}" > /dev/null
echo "Triggered a search for missing/upcoming episodes."
fi
}
cmd_scan() {
local folder="${1:-}"
if [[ -z "$folder" ]]; then
echo "Usage: $0 scan <folder>" >&2
exit 1
fi
api_get_query "manualimport" --data-urlencode "folder=$folder" --data-urlencode "filterExistingFiles=true" \
> /tmp/sonarr_scan_result.json
python3 -c "
import json
d = json.load(open('/tmp/sonarr_scan_result.json'))
if isinstance(d, dict) and 'message' in d:
print('Error:', d['message'])
raise SystemExit(1)
if not d:
print('No files found (or all already imported).')
raise SystemExit(0)
for f in d:
series = f.get('series') or {}
eps = f.get('episodes') or []
epstr = ', '.join(f\"S{e['seasonNumber']:02d}E{e['episodeNumber']:02d}\" for e in eps) or '(no episode match)'
rejections = [r['reason'] for r in f.get('rejections', [])]
flag = ' !! ' + '; '.join(rejections) if rejections else ''
print(f\"{f['name']}\")
print(f\" -> {series.get('title','?')} {epstr}{flag}\")
print()
print(f'{len(d)} file(s). Full detail saved to /tmp/sonarr_scan_result.json')
print('If this all looks right, run: ./sonarr.sh import \"$folder\"')
"
}
cmd_import() {
local folder="${1:-}"
if [[ -z "$folder" ]]; then
echo "Usage: $0 import <folder>" >&2
exit 1
fi
echo "Scanning $folder..."
api_get_query "manualimport" --data-urlencode "folder=$folder" --data-urlencode "filterExistingFiles=true" \
> /tmp/sonarr_import_scan.json
local has_rejections
has_rejections=$(python3 -c "
import json
d = json.load(open('/tmp/sonarr_import_scan.json'))
if isinstance(d, dict) and 'message' in d:
print('ERROR:' + d['message'])
raise SystemExit(0)
if not d:
print('EMPTY')
raise SystemExit(0)
bad = [f for f in d if f.get('rejections') or not f.get('episodes') or not f.get('series')]
if bad:
print('REJECTED')
for f in bad:
reasons = '; '.join(r['reason'] for r in f.get('rejections', [])) or 'no series/episode match'
print(f\" - {f['name']}: {reasons}\", file=__import__('sys').stderr)
else:
print('OK')
")
case "$has_rejections" in
OK) : ;;
EMPTY)
echo "Nothing to import (folder empty or already imported)."
return 0
;;
REJECTED)
echo "Refusing to auto-import: some files have no clean match or were rejected." >&2
echo "Run './sonarr.sh scan \"$folder\"' to see details, resolve manually in the Sonarr UI's Manual Import screen instead." >&2
exit 1
;;
ERROR:*)
echo "Error: ${has_rejections#ERROR:}" >&2
exit 1
;;
esac
local payload
payload=$(python3 -c "
import json
d = json.load(open('/tmp/sonarr_import_scan.json'))
files = []
for f in d:
files.append({
'path': f['path'],
'seriesId': f['series']['id'],
'episodeIds': [e['id'] for e in f['episodes']],
'quality': f['quality'],
'languages': f['languages'],
'releaseGroup': f.get('releaseGroup'),
'indexerFlags': f.get('indexerFlags', 0),
'downloadId': f.get('downloadId'),
})
print(json.dumps({'name': 'ManualImport', 'files': files, 'importMode': 'auto'}))
")
echo "Importing $(python3 -c "import json;print(len(json.load(open('/tmp/sonarr_import_scan.json'))))") file(s)..."
local response
response=$(api_post "command" "$payload")
local command_id
command_id=$(echo "$response" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
# Poll for completion
for _ in $(seq 1 30); do
sleep 1
local status
status=$(api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['status'])")
if [[ "$status" == "completed" ]]; then
api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print('Done:', d.get('message', 'completed'))"
return 0
elif [[ "$status" == "failed" ]]; then
api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print('Failed:', d.get('message', 'unknown error'))"
exit 1
fi
done
echo "Still running after 30s - check Sonarr Activity tab for status."
}
cmd_queue() {
api_get "queue" | python3 -c "
import json, sys
d = json.load(sys.stdin)
records = d.get('records', [])
if not records:
print('Queue is empty.')
for r in records:
print(f\"{r.get('series',{}).get('title','?')} {r.get('episode',{}).get('title','?')}\t{r.get('status')}\t{r.get('trackedDownloadStatus','')}\")
"
}
cmd_test_client() {
api_get "downloadclient" | python3 -c "
import json, sys
clients = json.load(sys.stdin)
for c in clients:
print(f\"Testing {c['name']} (id={c['id']})...\")
"
local ids
ids=$(api_get "downloadclient" | python3 -c "import json,sys; [print(c['id']) for c in json.load(sys.stdin)]")
for id in $ids; do
local dc_json
dc_json=$(api_get "downloadclient/$id")
local result
result=$(curl -s -X POST "$API/downloadclient/test?forceTest=true" -H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$dc_json")
if [[ "$result" == "{}" ]]; then
echo " OK"
else
echo " FAILED: $result"
fi
done
}
# --------------------------------------------------------------------------
# Dispatch
# --------------------------------------------------------------------------
command="${1:-}"
case "$command" in
lookup) cmd_lookup "${2:-}" ;;
add) cmd_add "${2:-}" "${3:-}" ;;
list) cmd_list ;;
rootfolders) cmd_rootfolders ;;
monitor) cmd_monitor "${2:-}" "${3:-}" ;;
scan) cmd_scan "${2:-}" ;;
import) cmd_import "${2:-}" ;;
queue) cmd_queue ;;
test-client) cmd_test_client ;;
*)
echo "Usage: $0 <command> [args]"
echo ""
echo "Commands:"
echo " lookup <term> Search TVDB for a show"
echo " add <tvdbId> [root] Add show to Sonarr (unmonitored, no search)"
echo " list List series already in Sonarr"
echo " rootfolders List root folders + unmapped folder counts"
echo " monitor <seriesId> [--search] Switch a show to monitored (auto-import new"
echo " episodes via tv-sonarr going forward); --search"
echo " also searches now for anything missing"
echo " scan <folder> Preview manual-import mapping for a raw folder"
echo " import <folder> Scan + apply import (aborts if any file is unmatched)"
echo " queue Show current download queue"
echo " test-client Test all configured download clients"
exit 1
;;
esac
+26
View File
@@ -0,0 +1,26 @@
volumes:
nas_media:
external: true
name: nas_media
services:
sonarr:
container_name: sonarr
image: lscr.io/linuxserver/sonarr:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
ports:
- 8989:8989/tcp
volumes:
- /srv/sonarr/config:/config
- nas_media:/data
networks:
- default
- npm-network
networks:
default:
npm-network:
external: true
+1
View File
@@ -0,0 +1 @@
__pycache__/
@@ -0,0 +1,151 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1994-01-08",
"tracks": [
{
"rank": 1,
"artist": "Gin Blossoms",
"title": "Found Out About You"
},
{
"rank": 2,
"artist": "Nirvana",
"title": "All Apologies"
},
{
"rank": 3,
"artist": "Pearl Jam",
"title": "Daughter"
},
{
"rank": 4,
"artist": "Rush",
"title": "Cold Fire"
},
{
"rank": 5,
"artist": "Cry Of Love",
"title": "Bad Thing"
},
{
"rank": 6,
"artist": "Cracker",
"title": "Low"
},
{
"rank": 7,
"artist": "Stone Temple Pilots",
"title": "Creep"
},
{
"rank": 8,
"artist": "Counting Crows",
"title": "Mr. Jones"
},
{
"rank": 9,
"artist": "Candlebox",
"title": "You"
},
{
"rank": 10,
"artist": "Blind Melon",
"title": "Tones Of Home"
},
{
"rank": 11,
"artist": "Guns N' Roses",
"title": "Hair Of The Dog"
},
{
"rank": 12,
"artist": "The Smashing Pumpkins",
"title": "Today"
},
{
"rank": 13,
"artist": "Beck",
"title": "Loser"
},
{
"rank": 14,
"artist": "Guns N' Roses",
"title": "Estranged"
},
{
"rank": 15,
"artist": "Seal And Jeff Beck",
"title": "Manic Depression"
},
{
"rank": 16,
"artist": "The Cure",
"title": "Purple Haze"
},
{
"rank": 17,
"artist": "Big Head Todd And The Monsters",
"title": "Bittersweet"
},
{
"rank": 18,
"artist": "Danzig",
"title": "Mother"
},
{
"rank": 19,
"artist": "Ian Moore",
"title": "Nothing"
},
{
"rank": 20,
"artist": "Tom Petty And The Heartbreakers",
"title": "Mary Jane's Last Dance"
},
{
"rank": 21,
"artist": "George Thorogood And The Destroyers",
"title": "Gone Dead Train"
},
{
"rank": 22,
"artist": "Melissa Etheridge",
"title": "Come To My Window"
},
{
"rank": 23,
"artist": "Brother Cane",
"title": "That Don't Satisfy Me"
},
{
"rank": 24,
"artist": "Aerosmith",
"title": "Amazing"
},
{
"rank": 26,
"artist": "U2",
"title": "Stay (Faraway, So Close!)"
},
{
"rank": 27,
"artist": "ZZ Top",
"title": "Pincushion"
},
{
"rank": 28,
"artist": "John Hiatt",
"title": "Something Wild"
},
{
"rank": 29,
"artist": "Fight",
"title": "Little Crazy"
},
{
"rank": 30,
"artist": "Bjork",
"title": "Big Time Sensuality"
}
]
}
@@ -0,0 +1,126 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1994-04-09",
"tracks": [
{
"rank": 1,
"artist": "Pink Floyd",
"title": "Keep Talking"
},
{
"rank": 2,
"artist": "Soundgarden",
"title": "Spoonman"
},
{
"rank": 3,
"artist": "Yes",
"title": "The Calling"
},
{
"rank": 4,
"artist": "The Smashing Pumpkins",
"title": "Disarm"
},
{
"rank": 5,
"artist": "Alice In Chains",
"title": "No Excuses"
},
{
"rank": 6,
"artist": "Brother Cane",
"title": "Hard Act To Follow"
},
{
"rank": 7,
"artist": "Sammy Hagar",
"title": "High Hopes"
},
{
"rank": 8,
"artist": "Meat Puppets",
"title": "Backwater"
},
{
"rank": 9,
"artist": "Pearl Jam",
"title": "Dissident"
},
{
"rank": 10,
"artist": "Crash Test Dummies",
"title": "Mmm Mmm Mmm Mmm"
},
{
"rank": 11,
"artist": "Rush",
"title": "Nobody's Hero"
},
{
"rank": 12,
"artist": "Morrissey",
"title": "The More You Ignore Me, The Closer I Get"
},
{
"rank": 13,
"artist": "Aerosmith",
"title": "Deuces Are Wild"
},
{
"rank": 14,
"artist": "Cheap Trick",
"title": "Woke Up With A Monster"
},
{
"rank": 15,
"artist": "Cry Of Love",
"title": "Too Cold In The Winter"
},
{
"rank": 18,
"artist": "David Lee Roth",
"title": "She's My Machine"
},
{
"rank": 19,
"artist": "ZZ Top",
"title": "Breakaway"
},
{
"rank": 20,
"artist": "Fury In The Slaughterhouse",
"title": "Every Generation Got Its Own Disease"
},
{
"rank": 21,
"artist": "Enigma",
"title": "Return To Innocence"
},
{
"rank": 23,
"artist": "Bonnie Raitt",
"title": "Love Sneakin' Up On You"
},
{
"rank": 24,
"artist": "Possum Dixon",
"title": "Watch The Girl Destroy Me"
},
{
"rank": 27,
"artist": "Sass Jordan",
"title": "High Road Easy"
},
{
"rank": 28,
"artist": "Blue Murder",
"title": "We All Fall Down"
},
{
"rank": 29,
"artist": "Melissa Etheridge",
"title": "All American Girl"
}
]
}
@@ -0,0 +1,141 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1994-07-09",
"tracks": [
{
"rank": 1,
"artist": "Stone Temple Pilots",
"title": "Vasoline"
},
{
"rank": 2,
"artist": "Live",
"title": "Selling The Drama"
},
{
"rank": 3,
"artist": "The Rolling Stones",
"title": "Love Is Strong"
},
{
"rank": 4,
"artist": "Soundgarden",
"title": "Black Hole Sun"
},
{
"rank": 5,
"artist": "Alice In Chains",
"title": "I Stay Away"
},
{
"rank": 7,
"artist": "The Offspring",
"title": "Come Out And Play"
},
{
"rank": 8,
"artist": "Toad The Wet Sprocket",
"title": "Fall Down"
},
{
"rank": 10,
"artist": "Stone Temple Pilots",
"title": "Big Empty"
},
{
"rank": 11,
"artist": "Great White",
"title": "Sail Away"
},
{
"rank": 13,
"artist": "Cracker",
"title": "Get Off This"
},
{
"rank": 14,
"artist": "John Mellencamp/Me'shell Ndegeocello",
"title": "Wild Night"
},
{
"rank": 16,
"artist": "Blur",
"title": "Girls & Boys"
},
{
"rank": 17,
"artist": "Seal",
"title": "Prayer For The Dying"
},
{
"rank": 18,
"artist": "Pride & Glory",
"title": "Losin' Your Mind"
},
{
"rank": 19,
"artist": "Crash Test Dummies",
"title": "Afternoons & Coffeespoons"
},
{
"rank": 20,
"artist": "Pink Floyd",
"title": "Take It Back"
},
{
"rank": 21,
"artist": "Lisa Loeb & Nine Stories",
"title": "Stay (I Missed You)"
},
{
"rank": 22,
"artist": "Spin Doctors",
"title": "You Let Your Heart Go Too Fast"
},
{
"rank": 23,
"artist": "The Smashing Pumpkins",
"title": "Rocket"
},
{
"rank": 24,
"artist": "Gary Hoey",
"title": "Low Rider"
},
{
"rank": 25,
"artist": "Rob Rule",
"title": "She Gets Too High"
},
{
"rank": 26,
"artist": "The Steve Miller Band",
"title": "Rock It"
},
{
"rank": 27,
"artist": "Yes",
"title": "Walls"
},
{
"rank": 28,
"artist": "Lenny Kravitz",
"title": "Deuce"
},
{
"rank": 29,
"artist": "Steve Perry",
"title": "You Better Wait"
},
{
"rank": 30,
"artist": "The Pretenders",
"title": "Night In My Veins"
},
{
"rank": 15,
"artist": "Pearl Jam",
"title": "Elderly Woman Behind The Counter In A Small Town"
}
]
}
@@ -0,0 +1,136 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1994-10-15",
"tracks": [
{
"rank": 1,
"artist": "Stone Temple Pilots",
"title": "Interstate Love Song"
},
{
"rank": 2,
"artist": "R.E.M.",
"title": "What's The Frequency, Kenneth?"
},
{
"rank": 3,
"artist": "Live",
"title": "I Alone"
},
{
"rank": 6,
"artist": "Dinosaur Jr.",
"title": "Feel The Pain"
},
{
"rank": 7,
"artist": "The Rolling Stones",
"title": "You Got Me Rocking"
},
{
"rank": 10,
"artist": "Nirvana",
"title": "About A Girl"
},
{
"rank": 11,
"artist": "The Cult",
"title": "Coming Down (Drug Tongue)"
},
{
"rank": 12,
"artist": "Pink Floyd",
"title": "High Hopes"
},
{
"rank": 13,
"artist": "Weezer",
"title": "Undone - The Sweater Song"
},
{
"rank": 14,
"artist": "Jimmy Page & Robert Plant",
"title": "Gallows Pole"
},
{
"rank": 15,
"artist": "Eric Clapton",
"title": "I'm Tore Down"
},
{
"rank": 16,
"artist": "Green Day",
"title": "Welcome To Paradise"
},
{
"rank": 17,
"artist": "Gin Blossoms",
"title": "Allison Road"
},
{
"rank": 18,
"artist": "Toad The Wet Sprocket",
"title": "Something's Always Wrong"
},
{
"rank": 19,
"artist": "Veruca Salt",
"title": "Seether"
},
{
"rank": 20,
"artist": "The Smashing Pumpkins",
"title": "Landslide"
},
{
"rank": 21,
"artist": "Queensryche",
"title": "I Am I"
},
{
"rank": 22,
"artist": "Liz Phair",
"title": "Supernova"
},
{
"rank": 23,
"artist": "Eagles",
"title": "Get Over It"
},
{
"rank": 24,
"artist": "Cowboy Junkies",
"title": "Sweet Jane"
},
{
"rank": 25,
"artist": "Pearl Jam",
"title": "Yellow Ledbetter"
},
{
"rank": 27,
"artist": "Pantera",
"title": "Planet Caravan"
},
{
"rank": 28,
"artist": "Gilby Clarke",
"title": "Cure Me... Or Kill Me"
},
{
"rank": 29,
"artist": "Mazzy Star",
"title": "Fade Into You"
},
{
"rank": 30,
"artist": "Peter Gabriel",
"title": "Red Rain"
},
{
"rank": 9,
"artist": "Green Day",
"title": "Basket Case"
}
]
}
@@ -0,0 +1,36 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1995-01-14",
"tracks": [
{"rank": 1, "artist": "Green Day", "title": "When I Come Around"},
{"rank": 2, "artist": "Pearl Jam", "title": "Better Man"},
{"rank": 3, "artist": "The Flaming Lips", "title": "She Don't Use Jelly"},
{"rank": 4, "artist": "Weezer", "title": "Buddy Holly"},
{"rank": 5, "artist": "Van Halen", "title": "Don't Tell Me (What Love Can Do)"},
{"rank": 6, "artist": "The Offspring", "title": "Gotta Get Away"},
{"rank": 7, "artist": "Bush", "title": "Everything Zen"},
{"rank": 8, "artist": "Portishead", "title": "Sour Times (Nobody Loves Me)"},
{"rank": 9, "artist": "Nine Inch Nails", "title": "Piggy"},
{"rank": 10, "artist": "Alice In Chains", "title": "Got Me Wrong"},
{"rank": 11, "artist": "Tom Petty", "title": "You Wreck Me"},
{"rank": 12, "artist": "Queensryche", "title": "Bridge"},
{"rank": 13, "artist": "Oasis", "title": "Live Forever"},
{"rank": 14, "artist": "The Stone Roses", "title": "Love Spreads"},
{"rank": 15, "artist": "Stone Temple Pilots", "title": "Unglued"},
{"rank": 16, "artist": "Pearl Jam", "title": "Corduroy"},
{"rank": 17, "artist": "Counting Crows", "title": "A Murder Of One"},
{"rank": 18, "artist": "Tom Petty", "title": "You Don't Know How It Feels"},
{"rank": 19, "artist": "R.E.M.", "title": "Bang And Blame"},
{"rank": 20, "artist": "Jimmy Page & Robert Plant", "title": "Thank You"},
{"rank": 21, "artist": "Aerosmith", "title": "Blind Man"},
{"rank": 22, "artist": "Nirvana", "title": "The Man Who Sold The World"},
{"rank": 23, "artist": "The Cranberries", "title": "Ode To My Family"},
{"rank": 24, "artist": "Rancid", "title": "Roots Radical"},
{"rank": 25, "artist": "Pete Droge", "title": "If You Don't Love Me (I'll Kill Myself)"},
{"rank": 26, "artist": "Soundgarden", "title": "Fell On Black Days"},
{"rank": 27, "artist": "Sponge", "title": "Plowed"},
{"rank": 28, "artist": "The Cranberries", "title": "Zombie"},
{"rank": 29, "artist": "Throwing Muses", "title": "Bright Yellow Gun"},
{"rank": 30, "artist": "Hole", "title": "Doll Parts"}
]
}
@@ -0,0 +1,156 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1995-06-10",
"tracks": [
{
"rank": 1,
"artist": "Soul Asylum",
"title": "Runaway Train"
},
{
"rank": 2,
"artist": "Collective Soul",
"title": "December"
},
{
"rank": 3,
"artist": "Live",
"title": "All Over You"
},
{
"rank": 4,
"artist": "U2",
"title": "Hold Me, Thrill Me, Kiss Me, Kill Me"
},
{
"rank": 5,
"artist": "Green Day",
"title": "She"
},
{
"rank": 6,
"artist": "R.E.M.",
"title": "Strange Currencies"
},
{
"rank": 7,
"artist": "Bush",
"title": "Little Things"
},
{
"rank": 8,
"artist": "Nirvana",
"title": "Lake Of Fire"
},
{
"rank": 9,
"artist": "White Zombie",
"title": "More Human Than Human"
},
{
"rank": 10,
"artist": "Sponge",
"title": "Molly (Sixteen Candles)"
},
{
"rank": 11,
"artist": "The Magnificent Bastards",
"title": "Mockingbird Girl"
},
{
"rank": 12,
"artist": "Radiohead",
"title": "Fake Plastic Trees"
},
{
"rank": 13,
"artist": "Blues Traveler",
"title": "Run-Around"
},
{
"rank": 14,
"artist": "Nine Inch Nails",
"title": "Hurt"
},
{
"rank": 15,
"artist": "Filter",
"title": "Hey Man, Nice Shot"
},
{
"rank": 16,
"artist": "Catherine Wheel",
"title": "Waydown"
},
{
"rank": 17,
"artist": "The Black Crowes",
"title": "Wiser Time"
},
{
"rank": 18,
"artist": "Weezer",
"title": "Say It Ain't So"
},
{
"rank": 19,
"artist": "Monster Magnet",
"title": "Negasonic Teenage Warhead"
},
{
"rank": 20,
"artist": "The Rembrandts",
"title": "I'll Be There For You"
},
{
"rank": 21,
"artist": "The Offspring",
"title": "Self Esteem"
},
{
"rank": 22,
"artist": "Van Halen",
"title": "Amsterdam"
},
{
"rank": 23,
"artist": "Hum",
"title": "Stars"
},
{
"rank": 24,
"artist": "Mad Season",
"title": "River Of Deceit"
},
{
"rank": 25,
"artist": "The Cranberries",
"title": "Ridiculous Thoughts"
},
{
"rank": 26,
"artist": "Better Than Ezra",
"title": "Good"
},
{
"rank": 27,
"artist": "Pink Floyd",
"title": "What Do You Want From Me"
},
{
"rank": 28,
"artist": "Tom Petty",
"title": "It's Good To Be King"
},
{
"rank": 29,
"artist": "Stone Temple Pilots",
"title": "Dancing Days"
},
{
"rank": 30,
"artist": "Jill Sobule",
"title": "I Kissed A Girl"
}
]
}
@@ -0,0 +1,36 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1995-10-28",
"tracks": [
{"rank": 1, "artist": "The Smashing Pumpkins", "title": "Bullet With Butterfly Wings"},
{"rank": 2, "artist": "Foo Fighters", "title": "I'll Stick Around"},
{"rank": 3, "artist": "Red Hot Chili Peppers", "title": "My Friends"},
{"rank": 4, "artist": "Green Day", "title": "Geek Stink Breath"},
{"rank": 5, "artist": "Alanis Morissette", "title": "Hand In My Pocket"},
{"rank": 6, "artist": "Goo Goo Dolls", "title": "Name"},
{"rank": 7, "artist": "Toadies", "title": "Possum Kingdom"},
{"rank": 8, "artist": "Ozzy Osbourne", "title": "Perry Mason"},
{"rank": 9, "artist": "Seven Mary Three", "title": "Cumbersome"},
{"rank": 10, "artist": "Alice In Chains", "title": "Grind"},
{"rank": 11, "artist": "The Presidents Of The United States Of America", "title": "Lump"},
{"rank": 12, "artist": "Toad The Wet Sprocket", "title": "Good Intentions"},
{"rank": 13, "artist": "Garbage", "title": "Queer"},
{"rank": 14, "artist": "Melissa Etheridge", "title": "Your Little Secret"},
{"rank": 15, "artist": "Oasis", "title": "Morning Glory"},
{"rank": 16, "artist": "Edwyn Collins", "title": "A Girl Like You"},
{"rank": 17, "artist": "AC/DC", "title": "Hard As A Rock"},
{"rank": 18, "artist": "Blues Traveler", "title": "Hook"},
{"rank": 19, "artist": "Folk Implosion", "title": "Natural One"},
{"rank": 20, "artist": "The Rentals", "title": "Friends Of P."},
{"rank": 21, "artist": "Candlebox", "title": "Simple Lessons"},
{"rank": 22, "artist": "Joan Osborne", "title": "One Of Us"},
{"rank": 23, "artist": "Rancid", "title": "Time Bomb"},
{"rank": 24, "artist": "Civ", "title": "Can't Wait One Minute More"},
{"rank": 25, "artist": "Heather Nova", "title": "Walk This World"},
{"rank": 26, "artist": "Sponge", "title": "Rainin'"},
{"rank": 27, "artist": "Lenny Kravitz", "title": "Rock And Roll Is Dead"},
{"rank": 28, "artist": "Deep Blue Something", "title": "Breakfast At Tiffany's"},
{"rank": 29, "artist": "Silverchair", "title": "Tomorrow"},
{"rank": 30, "artist": "Lisa Loeb & Nine Stories", "title": "Do You Sleep?"}
]
}
@@ -0,0 +1,91 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1995-12-16",
"tracks": [
{
"rank": 1,
"artist": "Bush",
"title": "Glycerine"
},
{
"rank": 2,
"artist": "Oasis",
"title": "Wonderwall"
},
{
"rank": 4,
"artist": "Pearl Jam",
"title": "I Got ID"
},
{
"rank": 6,
"artist": "Seven Mary Three",
"title": "Water's Edge"
},
{
"rank": 8,
"artist": "Alanis Morissette",
"title": "All I Really Want"
},
{
"rank": 9,
"artist": "Red Hot Chili Peppers",
"title": "Give It Away"
},
{
"rank": 10,
"artist": "The Presidents Of The United States Of America",
"title": "Kitty"
},
{
"rank": 12,
"artist": "Silverchair",
"title": "Pure Massacre"
},
{
"rank": 14,
"artist": "Alice In Chains",
"title": "Would?"
},
{
"rank": 15,
"artist": "Foo Fighters",
"title": "This Is A Call"
},
{
"rank": 18,
"artist": "Better Than Ezra",
"title": "Circle of Friends"
},
{
"rank": 19,
"artist": "Tom Petty And The Heartbreakers",
"title": "Waiting For Tonight"
},
{
"rank": 21,
"artist": "Green Day",
"title": "Brain Stew"
},
{
"rank": 24,
"artist": "Dave Matthews Band",
"title": "What Would You Say"
},
{
"rank": 27,
"artist": "Natalie Merchant",
"title": "Carnival"
},
{
"rank": 28,
"artist": "Ruth Ruth",
"title": "Uninvited"
},
{
"rank": 29,
"artist": "The Beatles",
"title": "Free As A Bird"
}
]
}
@@ -0,0 +1,181 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1996-01-13",
"tracks": [
{
"rank": 1,
"artist": "Pearl Jam",
"title": "I Got ID"
},
{
"rank": 2,
"artist": "Bush",
"title": "Glycerine"
},
{
"rank": 3,
"artist": "Collective Soul",
"title": "Shine"
},
{
"rank": 4,
"artist": "Everclear",
"title": "Santa Monica (Watch The World Die)"
},
{
"rank": 5,
"artist": "Green Day",
"title": "Longview"
},
{
"rank": 6,
"artist": "Red Hot Chili Peppers",
"title": "Under the Bridge"
},
{
"rank": 7,
"artist": "Red Hot Chili Peppers",
"title": "Aeroplane"
},
{
"rank": 8,
"artist": "Tom Petty And The Heartbreakers",
"title": "Waiting For Tonight"
},
{
"rank": 9,
"artist": "Rancid",
"title": "Ruby Soho"
},
{
"rank": 10,
"artist": "No Doubt",
"title": "Just A Girl"
},
{
"rank": 11,
"artist": "Dave Matthews Band",
"title": "Satellite"
},
{
"rank": 12,
"artist": "Ozzy Osbourne",
"title": "See You On The Other Side"
},
{
"rank": 13,
"artist": "Radiohead",
"title": "High And Dry"
},
{
"rank": 14,
"artist": "AC/DC",
"title": "Cover You In Oil"
},
{
"rank": 15,
"artist": "Goo Goo Dolls",
"title": "Naked"
},
{
"rank": 16,
"artist": "Poe",
"title": "Trigger Happy Jack (Drive By A Go-Go)"
},
{
"rank": 17,
"artist": "Alanis Morissette",
"title": "Ironic"
},
{
"rank": 18,
"artist": "Natalie Merchant",
"title": "Wonder"
},
{
"rank": 19,
"artist": "Tori Amos",
"title": "Caught A Lite Sneeze"
},
{
"rank": 20,
"artist": "For Squirrels",
"title": "Mighty K.C."
},
{
"rank": 21,
"artist": "Alice In Chains",
"title": "Grind"
},
{
"rank": 22,
"artist": "Loud Lucy",
"title": "Ticking"
},
{
"rank": 23,
"artist": "Silverchair",
"title": "Pure Massacre"
},
{
"rank": 24,
"artist": "Melissa Etheridge",
"title": "I Want To Come Over"
},
{
"rank": 25,
"artist": "Toadies",
"title": "I Come From The Water"
},
{
"rank": 26,
"artist": "The Badlees",
"title": "Fear Of Falling"
},
{
"rank": 27,
"artist": "Gin Blossoms",
"title": "Follow You Down"
},
{
"rank": 28,
"artist": "The Presidents Of The United States Of America",
"title": "Peaches"
},
{
"rank": 29,
"artist": "Soul Asylum",
"title": "Promises Broken"
},
{
"rank": 30,
"artist": "BoDeans",
"title": "Closer To Free"
},
{
"rank": 31,
"artist": "Don Henley",
"title": "The Garden Of Allah"
},
{
"rank": 34,
"artist": "Ruth Ruth",
"title": "Uninvited"
},
{
"rank": 1,
"artist": "The Smashing Pumpkins",
"title": "1979"
},
{
"rank": 5,
"artist": "Collective Soul",
"title": "The World I Know"
},
{
"rank": 11,
"artist": "Spacehog",
"title": "In The Meantime"
}
]
}
@@ -0,0 +1,146 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1996-04-13",
"tracks": [
{
"rank": 1,
"artist": "Bush",
"title": "Machinehead"
},
{
"rank": 2,
"artist": "Stone Temple Pilots",
"title": "Big Bang Baby"
},
{
"rank": 3,
"artist": "The Smashing Pumpkins",
"title": "Zero"
},
{
"rank": 4,
"artist": "Oasis",
"title": "Champagne Supernova"
},
{
"rank": 5,
"artist": "Jars Of Clay",
"title": "Flood"
},
{
"rank": 6,
"artist": "The Cranberries",
"title": "Salvation"
},
{
"rank": 7,
"artist": "Foo Fighters",
"title": "Big Me"
},
{
"rank": 8,
"artist": "Alanis Morissette",
"title": "Head Over Feet"
},
{
"rank": 9,
"artist": "The Nixons",
"title": "Sister"
},
{
"rank": 10,
"artist": "Stabbing Westward",
"title": "What Do I Have To Do?"
},
{
"rank": 11,
"artist": "Cracker",
"title": "I Hate My Generation"
},
{
"rank": 12,
"artist": "The Verve Pipe",
"title": "Photograph"
},
{
"rank": 13,
"artist": "Tracy Bonham",
"title": "Mother Mother"
},
{
"rank": 14,
"artist": "Dave Matthews Band",
"title": "Too Much"
},
{
"rank": 15,
"artist": "Collective Soul",
"title": "Where The River Flows"
},
{
"rank": 16,
"artist": "Dog's Eye View",
"title": "Everything Falls Apart"
},
{
"rank": 17,
"artist": "Dishwalla",
"title": "Counting Blue Cars"
},
{
"rank": 18,
"artist": "Seven Mary Three",
"title": "Water's Edge"
},
{
"rank": 19,
"artist": "Love And Rockets",
"title": "Sweet Lover Hangover"
},
{
"rank": 20,
"artist": "Son Volt",
"title": "Drown"
},
{
"rank": 21,
"artist": "Cowboy Junkies",
"title": "A Common Disaster"
},
{
"rank": 22,
"artist": "Bad Religion",
"title": "A Walk"
},
{
"rank": 24,
"artist": "AC/DC",
"title": "Ballbreaker"
},
{
"rank": 25,
"artist": "Hootie & The Blowfish",
"title": "Old Man & Me (When I Get To Heaven)"
},
{
"rank": 26,
"artist": "Rage Against The Machine",
"title": "Bulls On Parade"
},
{
"rank": 27,
"artist": "Goldfinger",
"title": "Here In Your Bedroom"
},
{
"rank": 29,
"artist": "Kiss",
"title": "Rock And Roll All Nite"
},
{
"rank": 30,
"artist": "Garbage",
"title": "Only Happy When It Rains"
}
]
}
@@ -0,0 +1,151 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1996-07-13",
"tracks": [
{
"rank": 1,
"artist": "Primitive Radio Gods",
"title": "Standing Outside A Broken Phone Booth With Money In My Hand"
},
{
"rank": 2,
"artist": "Stone Temple Pilots",
"title": "Trippin' On A Hole In A Paper Heart"
},
{
"rank": 3,
"artist": "Butthole Surfers",
"title": "Pepper"
},
{
"rank": 4,
"artist": "Metallica",
"title": "Until It Sleeps"
},
{
"rank": 5,
"artist": "The Smashing Pumpkins",
"title": "Tonight, Tonight"
},
{
"rank": 6,
"artist": "Garbage",
"title": "Stupid Girl"
},
{
"rank": 7,
"artist": "Soundgarden",
"title": "Burden In My Hand"
},
{
"rank": 8,
"artist": "Beck",
"title": "Where It's At"
},
{
"rank": 9,
"artist": "The Cranberries",
"title": "Free To Decide"
},
{
"rank": 10,
"artist": "Nada Surf",
"title": "Popular"
},
{
"rank": 11,
"artist": "Sponge",
"title": "Wax Ecstatic (To Sell Angelina)"
},
{
"rank": 12,
"artist": "Oasis",
"title": "Don't Look Back In Anger"
},
{
"rank": 13,
"artist": "Screaming Trees",
"title": "All I Know"
},
{
"rank": 14,
"artist": "Green Day",
"title": "Walking Contradiction"
},
{
"rank": 15,
"artist": "Porno For Pyros",
"title": "Tahitian Moon"
},
{
"rank": 16,
"artist": "The Cure",
"title": "Mint Car"
},
{
"rank": 17,
"artist": "Goo Goo Dolls",
"title": "Long Way Down"
},
{
"rank": 18,
"artist": "No Doubt",
"title": "Spiderwebs"
},
{
"rank": 19,
"artist": "Alice In Chains",
"title": "Again"
},
{
"rank": 20,
"artist": "The Black Crowes",
"title": "Good Friday"
},
{
"rank": 21,
"artist": "The Hunger",
"title": "Vanishing Cream"
},
{
"rank": 22,
"artist": "Alanis Morissette",
"title": "You Learn"
},
{
"rank": 23,
"artist": "The Wallflowers",
"title": "6th Avenue Heartache"
},
{
"rank": 24,
"artist": "311",
"title": "Down"
},
{
"rank": 25,
"artist": "Jewel",
"title": "Who Will Save Your Soul"
},
{
"rank": 26,
"artist": "Superdrag",
"title": "Sucked Out"
},
{
"rank": 27,
"artist": "Cracker",
"title": "Nothing To Believe In"
},
{
"rank": 28,
"artist": "The Hazies",
"title": "Skin & Bones"
},
{
"rank": 29,
"artist": "Soundgarden",
"title": "Pretty Noose"
}
]
}
@@ -0,0 +1,156 @@
{
"station": "Tunecaster Rock Top 30",
"date": "1996-12-14",
"tracks": [
{
"rank": 1,
"artist": "Soundgarden",
"title": "Blow Up The Outside World"
},
{
"rank": 2,
"artist": "Garbage",
"title": "#1 Crush"
},
{
"rank": 3,
"artist": "Stone Temple Pilots",
"title": "Lady Picture Show"
},
{
"rank": 4,
"artist": "311",
"title": "All Mixed Up"
},
{
"rank": 5,
"artist": "Cake",
"title": "The Distance"
},
{
"rank": 6,
"artist": "No Doubt",
"title": "Don't Speak"
},
{
"rank": 7,
"artist": "Tool",
"title": "Stinkfist"
},
{
"rank": 8,
"artist": "Bush",
"title": "Swallowed"
},
{
"rank": 9,
"artist": "Local H",
"title": "Bound For The Floor"
},
{
"rank": 10,
"artist": "The Smashing Pumpkins",
"title": "Thirty-Three"
},
{
"rank": 11,
"artist": "Red Hot Chili Peppers",
"title": "Love Rollercoaster"
},
{
"rank": 12,
"artist": "Alice In Chains",
"title": "Down in a Hole"
},
{
"rank": 13,
"artist": "Counting Crows",
"title": "A Long December"
},
{
"rank": 14,
"artist": "The Wallflowers",
"title": "One Headlight"
},
{
"rank": 15,
"artist": "Better Than Ezra",
"title": "Desperately Wanting"
},
{
"rank": 16,
"artist": "The Presidents Of The United States Of America",
"title": "Mach 5"
},
{
"rank": 17,
"artist": "Poe",
"title": "Hello"
},
{
"rank": 18,
"artist": "Kula Shaker",
"title": "Tattva"
},
{
"rank": 19,
"artist": "Luscious Jackson",
"title": "Naked Eye"
},
{
"rank": 20,
"artist": "Soul Coughing",
"title": "Super Bon Bon"
},
{
"rank": 21,
"artist": "Metallica",
"title": "Hero Of The Day"
},
{
"rank": 22,
"artist": "Sublime",
"title": "What I Got"
},
{
"rank": 23,
"artist": "Rush",
"title": "Half The World"
},
{
"rank": 24,
"artist": "Matchbox 20",
"title": "Long Day"
},
{
"rank": 25,
"artist": "The Bloodhound Gang",
"title": "Fire Water Burn"
},
{
"rank": 26,
"artist": "Phish",
"title": "Free"
},
{
"rank": 27,
"artist": "Fountains Of Wayne",
"title": "Radiation Vibe"
},
{
"rank": 28,
"artist": "Sponge",
"title": "Have You Seen Mary"
},
{
"rank": 29,
"artist": "John Mellencamp",
"title": "Just Another Day"
},
{
"rank": 30,
"artist": "Fiona Apple",
"title": "Shadowboxer"
}
]
}
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""
Recreate a historical radio broadcast (or a national chart for a given week)
as a SUB/WAVE show, from a hand-transcribed track list.
Input is a small JSON file:
{
"station": "WABC",
"date": "1968-10-19",
"tracks": [
{"rank": 1, "artist": "The Beatles", "title": "Hey Jude"},
{"rank": 2, "artist": "O.C. Smith", "title": "Little Green Apples"}
]
}
"station" is really just a chart label -- a call sign, or something like
"Billboard Modern Rock Tracks". This script never fetches anything from any
chart/survey web site itself; it only ever consumes this structured input,
which you transcribe by hand.
Each track is matched against the Navidrome library, matched tracks become a
Navidrome playlist, and that playlist is wired into a SUB/WAVE show
(playlistStrict=true, so the show plays only these tracks).
Usage:
subwave/recreate_broadcast.py subwave/broadcasts/wabc-1968-10-19.json
subwave/recreate_broadcast.py subwave/broadcasts/wabc-1968-10-19.json --commit -y
subwave/recreate_broadcast.py subwave/broadcasts/wabc-1968-10-19.json --commit -y --play-now 60
"""
import argparse
import difflib
import hashlib
import json
import re
import secrets
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_SETUP_CONFIG = "/srv/subwave/state/setup-config.json"
DEFAULT_ENV_FILE = SCRIPT_DIR / ".env"
DEFAULT_CONTROLLER_CONTAINER = "sub-wave-controller"
SUBSONIC_CLIENT = "subwave-recreate-broadcast"
SUBSONIC_VERSION = "1.16.1"
STOPWORDS = {"the", "a", "an", "and", "of", "feat", "featuring", "ft"}
# --------------------------------------------------------------------------
# Connection resolution
# --------------------------------------------------------------------------
def parse_env_file(path):
values = {}
if not path.exists():
return values
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
values[key.strip()] = val.strip()
return values
def load_navidrome_config(args):
if args.navidrome_url and args.navidrome_user and args.navidrome_pass:
return {"url": args.navidrome_url.rstrip("/"), "user": args.navidrome_user, "pass": args.navidrome_pass}
cfg = json.loads(Path(args.setup_config).read_text())["navidrome"]
return {"url": cfg["url"].rstrip("/"), "user": cfg["user"], "pass": cfg["pass"]}
def load_admin_config(args):
user, pw = args.admin_user, args.admin_pass
if not (user and pw):
env = parse_env_file(DEFAULT_ENV_FILE)
user = user or env.get("ADMIN_USER", "")
pw = pw or env.get("ADMIN_PASS", "")
return user, pw
# --------------------------------------------------------------------------
# Subsonic (Navidrome) client
# --------------------------------------------------------------------------
def subsonic_call(nd_cfg, endpoint, params=None, retry_local=True):
salt = secrets.token_hex(8)
token = hashlib.md5((nd_cfg["pass"] + salt).encode("utf-8")).hexdigest()
q = {"u": nd_cfg["user"], "t": token, "s": salt, "v": SUBSONIC_VERSION, "c": SUBSONIC_CLIENT, "f": "json"}
q.update(params or {})
url = f"{nd_cfg['url']}/rest/{endpoint}?{urllib.parse.urlencode(q, doseq=True)}"
try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.load(r)
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
if retry_local and "navidrome:" in nd_cfg["url"]:
fallback = dict(nd_cfg, url=nd_cfg["url"].replace("navidrome:", "localhost:"))
return subsonic_call(fallback, endpoint, params, retry_local=False)
raise RuntimeError(f"could not reach Navidrome at {nd_cfg['url']}: {e}") from e
sub = data["subsonic-response"]
if sub.get("status") != "ok":
raise RuntimeError(f"Subsonic {endpoint} failed: {sub.get('error')}")
return sub
def search3(nd_cfg, query, song_count=10):
if not query.strip():
return []
sub = subsonic_call(nd_cfg, "search3", {"query": query, "songCount": song_count, "artistCount": 0, "albumCount": 0})
return sub.get("searchResult3", {}).get("song", [])
def get_playlists(nd_cfg):
return subsonic_call(nd_cfg, "getPlaylists").get("playlists", {}).get("playlist", []) or []
def create_playlist(nd_cfg, name, song_ids, playlist_id=None):
"""Create a playlist, or (if playlist_id given) fully replace its contents."""
params = {"name": name}
if playlist_id:
params["playlistId"] = playlist_id
first_chunk, remaining = song_ids[:100], song_ids[100:]
params["songId"] = first_chunk
sub = subsonic_call(nd_cfg, "createPlaylist", params)
new_id = sub["playlist"]["id"]
while remaining:
chunk, remaining = remaining[:100], remaining[100:]
subsonic_call(nd_cfg, "updatePlaylist", {"playlistId": new_id, "songIdToAdd": chunk})
subsonic_call(nd_cfg, "updatePlaylist", {"playlistId": new_id, "public": "true"})
return new_id
# --------------------------------------------------------------------------
# Matching
# --------------------------------------------------------------------------
def normalize(s):
s = re.sub(r"[^\w\s]", " ", s.lower())
return re.sub(r"\s+", " ", s).strip()
def strip_parens(s):
return re.sub(r"\s*[\(\[][^\)\]]*[\)\]]", "", s).strip()
def strip_stopwords(s):
words = [w for w in normalize(s).split() if w not in STOPWORDS]
return " ".join(words) if words else normalize(s)
def similarity(a, b):
return difflib.SequenceMatcher(None, normalize(a), normalize(b)).ratio()
def contains_normalized(a, b):
"""True if the normalized forms of a/b contain one another as whole words.
Handles library tags that carry extra baggage a chart title/artist won't --
reissue composite credits ("Green Day • Billie Joe Armstrong, Mike Dirnt,
& Tre Cool") or a subtitle the library dropped ("Sour Times" vs "Sour Times
(Nobody Loves Me)") -- without this a correct match can score just under
the threshold on artist or title alone even though one string is clearly
the other plus extra text.
"""
na, nb = f" {normalize(a)} ", f" {normalize(b)} "
return bool(na.strip()) and bool(nb.strip()) and (na in nb or nb in na)
# A hard floor, independent of the combined score: without it a generic title
# match (e.g. two different "Just Another Day"s) can drag a middling but not
# absurd artist-name coincidence (e.g. "Jon Secada" vs "John Mellencamp",
# 0.56) over the acceptance threshold on title strength alone. Legitimate
# matches -- exact names or the containment-boosted composite-credit/subtitle
# cases -- always clear this by a wide margin.
ARTIST_SCORE_FLOOR = 0.65
def score_candidate(song, artist, title):
title_score = similarity(song.get("title", ""), title)
if contains_normalized(song.get("title", ""), title):
title_score = max(title_score, 0.9)
artist_score = similarity(song.get("artist", ""), artist)
if contains_normalized(song.get("artist", ""), artist):
artist_score = 1.0
if artist_score < ARTIST_SCORE_FLOOR:
return 0.0
return title_score * 0.6 + artist_score * 0.4
def match_track(nd_cfg, artist, title, min_score):
attempts = [
("combined", f"{artist} {title}", 10),
("title-only", title, 20),
("keyword", f"{strip_stopwords(artist)} {strip_stopwords(strip_parens(title))}", 20),
]
for stage, query, count in attempts:
candidates = search3(nd_cfg, query, song_count=count)
if not candidates:
continue
best_score, best_song = max(((score_candidate(c, artist, title), c) for c in candidates), key=lambda x: x[0])
if best_score >= min_score:
status = "matched" if stage == "combined" and best_score >= 0.92 else "low-confidence"
return {"status": status, "score": best_score, "stage": stage, "song": best_song}
return {"status": "unmatched", "score": 0.0, "stage": None, "song": None}
# --------------------------------------------------------------------------
# Report
# --------------------------------------------------------------------------
STATUS_LABEL = {"matched": "MATCH", "low-confidence": "LOW ", "unmatched": "MISS "}
def print_report(tracks, results):
for track, result in zip(tracks, results):
rank = track.get("rank", "-")
src = f"{track['artist']} — {track['title']}"
if result["song"]:
s = result["song"]
dst = f"nd:{s['id'][:8]} \"{s.get('title', '?')}\" by {s.get('artist', '?')} ({s.get('album', '?')})"
else:
dst = "(no match found)"
print(f"#{str(rank):>3} [{STATUS_LABEL[result['status']]}] {src:<50} -> {dst}")
matched = sum(1 for r in results if r["status"] == "matched")
low = sum(1 for r in results if r["status"] == "low-confidence")
missed = sum(1 for r in results if r["status"] == "unmatched")
print(f"\n{matched + low}/{len(results)} matched ({low} low-confidence, {missed} unmatched)")
return matched, low, missed
# --------------------------------------------------------------------------
# SUB/WAVE admin API (via docker exec -- the controller's port isn't
# published to the host)
# --------------------------------------------------------------------------
def admin_request(container, admin_user, admin_pass, method, path, body=None):
cmd = ["docker", "exec", container, "curl", "-s", "-X", method]
if admin_user and admin_pass:
cmd += ["-u", f"{admin_user}:{admin_pass}"]
if body is not None:
cmd += ["-H", "Content-Type: application/json", "--data", json.dumps(body)]
cmd += [f"http://localhost:7701{path}"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
if result.returncode != 0:
raise RuntimeError(f"docker exec into {container} failed: {result.stderr.strip()}")
try:
parsed = json.loads(result.stdout)
except json.JSONDecodeError:
raise RuntimeError(f"unexpected response from SUB/WAVE controller: {result.stdout[:300]!r}")
if isinstance(parsed, dict) and "error" in parsed:
raise RuntimeError(f"SUB/WAVE controller rejected {method} {path}: {parsed['error']}")
return parsed
def find_existing_show(settings, name):
for show in settings.get("shows", []) or []:
if show.get("name", "").strip().lower() == name.strip().lower():
return show
return None
def resolve_persona_id(settings, persona_id, persona_name):
personas = settings.get("personas", []) or []
if persona_id:
if any(p["id"] == persona_id for p in personas):
return persona_id
raise RuntimeError(f"no persona with id {persona_id!r} in the roster")
if persona_name:
for p in personas:
if p.get("name", "").strip().lower() == persona_name.strip().lower():
return p["id"]
raise RuntimeError(f"no persona named {persona_name!r} in the roster")
if settings.get("activePersonaId"):
return settings["activePersonaId"]
if personas:
return personas[0]["id"]
raise RuntimeError("no personas found in the SUB/WAVE roster")
# --------------------------------------------------------------------------
# Orchestration
# --------------------------------------------------------------------------
def load_input(path):
data = json.loads(Path(path).read_text())
tracks = data.get("tracks") or []
if not tracks:
raise RuntimeError("input file has no tracks")
return data["station"], data["date"], tracks
def run(args):
station, date, tracks = load_input(args.input)
show_name = f"{station} — {date}"
if args.limit:
tracks = tracks[: args.limit]
nd_cfg = load_navidrome_config(args)
print(f'Matching {len(tracks)} tracks for "{show_name}" against the Navidrome library...\n')
results = [match_track(nd_cfg, t["artist"], t["title"], args.min_match_score) for t in tracks]
matched, low, missed = print_report(tracks, results)
total_usable = matched + low
if total_usable < args.min_tracks and not args.force:
print(f"\nOnly {total_usable} usable match(es) (minimum {args.min_tracks}) — refusing to continue. Pass --force to override.")
sys.exit(1)
if not args.commit:
print("\nDry run only — pass --commit to create/update the playlist and show.")
return
if not args.yes:
reply = input(f'\nCreate/update "{show_name}" from {total_usable} matched track(s)? [y/N] ')
if reply.strip().lower() not in ("y", "yes"):
print("Aborted.")
return
song_ids = [r["song"]["id"] for r in results if r["song"]]
existing_playlist = next((p for p in get_playlists(nd_cfg) if p.get("name") == show_name), None)
playlist_id = create_playlist(nd_cfg, show_name, song_ids, playlist_id=existing_playlist["id"] if existing_playlist else None)
print(f'{"Updated" if existing_playlist else "Created"} Navidrome playlist "{show_name}" (id {playlist_id}) with {len(song_ids)} track(s).')
admin_user, admin_pass = load_admin_config(args)
settings = admin_request(args.controller_container, admin_user, admin_pass, "GET", "/settings").get("values", {})
persona_id = resolve_persona_id(settings, args.persona_id, args.persona_name)
existing_show = find_existing_show(settings, show_name)
show_body = {
"name": show_name,
"topic": f"A recreation of the {station} chart from {date}.",
"personaId": persona_id,
"guestPersonaIds": [],
"playlistIds": [playlist_id],
"playlistStrict": True,
# A playlist-pinned show's no-repeat window otherwise scales down to a
# fraction of the playlist size and disables entirely below 15 tracks
# (recency.ts's effectiveNoRepeatWindow) -- every playlist this script
# builds is well under that, so without this flag repeats are
# essentially unthrottled. playlistExhaust switches to a full-rotation
# window instead (nearly the whole playlist must play before a repeat).
"playlistExhaust": True,
"filtersStrict": True,
"moods": [], "genres": [], "energies": [], "eras": [],
"excludedPlaylistIds": [],
"maxTrackSeconds": 0,
}
if existing_show:
show_body["id"] = existing_show["id"]
resp = admin_request(args.controller_container, admin_user, admin_pass, "POST", "/shows", {"show": show_body})
show = resp["show"]
print(f'{"Updated" if existing_show else "Created"} SUB/WAVE show "{show["name"]}" (id {show["id"]}), hosted by persona {persona_id}.')
if args.play_now:
admin_request(
args.controller_container, admin_user, admin_pass, "POST", "/schedule/override",
{"showId": show["id"], "minutes": args.play_now, "until": "fixed"},
)
print(f'Takeover started: "{show_name}" airing now for {args.play_now} minute(s).')
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("input", help="path to a JSON file: {station, date, tracks: [{rank, artist, title}]}")
p.add_argument("--commit", action="store_true", help="create/update the playlist and show (default is dry-run)")
p.add_argument("-y", "--yes", action="store_true", help="skip the confirmation prompt when --commit is passed")
p.add_argument("--play-now", type=int, metavar="MINUTES", help="also start a live takeover for N minutes after creating the show (SUB/WAVE requires 15-720)")
p.add_argument("--persona-id", help="SUB/WAVE persona id to host the show")
p.add_argument("--persona-name", help="SUB/WAVE persona name to host the show (alternative to --persona-id)")
p.add_argument("--navidrome-url")
p.add_argument("--navidrome-user")
p.add_argument("--navidrome-pass")
p.add_argument("--setup-config", default=DEFAULT_SETUP_CONFIG, help=f"default: {DEFAULT_SETUP_CONFIG}")
p.add_argument("--admin-user")
p.add_argument("--admin-pass")
p.add_argument("--controller-container", default=DEFAULT_CONTROLLER_CONTAINER)
p.add_argument("--min-match-score", type=float, default=0.80)
p.add_argument("--min-tracks", type=int, default=3, help="minimum usable matches required to proceed (default: 3)")
p.add_argument("--force", action="store_true", help="proceed even if fewer than --min-tracks matched")
p.add_argument("--limit", type=int, help="only process the first N tracks (quick smoke test)")
return p.parse_args()
def main():
args = parse_args()
try:
run(args)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
# NFS Self-Heal (host-level systemd units)
These files are **not** deployed by Docker/Portainer — they're systemd units and a
script that live directly on the host at `/etc/systemd/system/` and
`/usr/local/bin/`. They're checked in here purely as a backup so they can be
restored if the host is rebuilt. Editing a file here does **not** change
running behavior; you have to re-copy it to the host and reload systemd.
## What this does
Permanent NFS mounts (`mnt-nas_family.mount`, `mnt-nas_books.mount`,
`mnt-nas_owncloud.mount`, `mnt-nas_audiobooks.mount`) can go stale — the host
mount looks fine on a top-level `ls`, but file handles nested inside it
return ESTALE (errno -116) to processes that opened them earlier, including
containers that bind-mount the path. A container restart is required to pick
up a fresh handle even after the host-side mount is healthy again. See
`../../NAS-CONNECTION-STRATEGIES.md` for the full mount inventory and
`../../immich/` / `../../calibre/MOUNT-HISTORY.md` for history on this
failure mode (first hit immich on 2026-08-25; calibre-import had the same
issue documented earlier but unresolved).
**All four `.mount` units must be *permanent* (enabled directly, no
`.automount` wrapper), not just present.** `mnt-nas_audiobooks.mount` was
mistakenly left on an `.automount` unit until 2026-09-17 and flapped ESTALE
for days as a result — autofs can cycle the underlying mount for reasons
other than idle timeout even with `TimeoutIdleSec=0`, which invalidates any
container's bind-mounted reference exactly like a stale handle. The self-heal
below papered over it (auto-remounting every ~5 min) but never fixed it;
switching to a permanent mount did. See NAS-CONNECTION-STRATEGIES.md's
"Docker bind mounts + automount = stale handles" entry.
Two pieces:
1. **`<container>-mount-ready.service`** (one per affected container) —
`BindsTo=mnt-nas_*.mount`, so restarting the mount unit automatically
restarts the bound container. Currently covers `immich_server`,
`calibre`, `ocis`, `audiobookshelf`. (Older instances of this same
pattern — `backrest`, `kiwix`, `pmtiles`, `romm` — already exist on
the host but aren't backed up here yet.)
2. **`nfs-mount-heal.timer`** → **`nfs-mount-heal.service`** →
**`nfs-mount-heal.sh`** — runs every 5 minutes, does a real nested
read (not just `ls` the mount root) against `nas_family`, `nas_books`,
`nas_owncloud`, and `nas_audiobooks`. On failure it force-remounts the
mount unit (`systemctl restart`), which cascades into the container
restart via the hooks above, then re-checks and sends a Telegram alert
either way. This is a safety net for genuine transient staleness, not a
fix for a mount that's structurally wrong (e.g. sitting on autofs).
## Reinstall after a host rebuild
```bash
sudo cp *.mount *.service *.timer /etc/systemd/system/
sudo cp nfs-mount-heal.sh /usr/local/bin/nfs-mount-heal.sh
sudo chmod 755 /usr/local/bin/nfs-mount-heal.sh
sudo systemctl daemon-reload
sudo systemctl enable --now mnt-nas_family.mount
sudo systemctl enable --now mnt-nas_books.mount
sudo systemctl enable --now mnt-nas_owncloud.mount
sudo systemctl enable --now mnt-nas_audiobooks.mount
sudo systemctl enable --now immich-mount-ready.service
sudo systemctl enable --now calibre-mount-ready.service
sudo systemctl enable --now ocis-mount-ready.service
sudo systemctl enable --now audiobookshelf-mount-ready.service
sudo systemctl enable --now nfs-mount-heal.timer
```
**Do not** create `.automount` units for any of these — they must be
enabled directly as permanent mounts (see the autofs warning above).
Requires `.credentials` at the repo root to be present on the host (the
heal script sources it for the Telegram bot token/chat ID).
@@ -0,0 +1,13 @@
[Unit]
Description=Restart audiobookshelf after mnt-nas_audiobooks is ready
After=mnt-nas_audiobooks.mount
Requires=mnt-nas_audiobooks.mount
BindsTo=mnt-nas_audiobooks.mount
[Service]
Type=oneshot
ExecStart=/usr/bin/docker restart audiobookshelf
RemainAfterExit=yes
[Install]
WantedBy=mnt-nas_audiobooks.mount
@@ -0,0 +1,13 @@
[Unit]
Description=Restart calibre after NAS books mount is ready
After=mnt-nas_books.mount
Requires=mnt-nas_books.mount
BindsTo=mnt-nas_books.mount
[Service]
Type=oneshot
ExecStart=/usr/bin/docker restart calibre
RemainAfterExit=yes
[Install]
WantedBy=mnt-nas_books.mount
@@ -0,0 +1,13 @@
[Unit]
Description=Restart immich_server after NAS family mount is ready
After=mnt-nas_family.mount
Requires=mnt-nas_family.mount
BindsTo=mnt-nas_family.mount
[Service]
Type=oneshot
ExecStart=/usr/bin/docker restart immich_server
RemainAfterExit=yes
[Install]
WantedBy=mnt-nas_family.mount
@@ -0,0 +1,13 @@
[Unit]
Description=Mount unRAID audiobooks NFS share
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.1.192:/mnt/user/media/audiobooks
Where=/mnt/nas_audiobooks
Type=nfs
Options=nfsvers=3,hard,rw,noatime
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Mount unRAID books NFS share
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.1.192:/mnt/user/media/books
Where=/mnt/nas_books
Type=nfs
Options=nfsvers=3,hard,rw,noatime
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Mount unRAID family NFS share
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.1.192:/mnt/user/family
Where=/mnt/nas_family
Type=nfs
Options=nfsvers=3,hard,rw,noatime
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Mount unRAID owncloud NFS share
After=network-online.target
Wants=network-online.target
[Mount]
What=192.168.1.192:/mnt/user/owncloud
Where=/mnt/nas_owncloud
Type=nfs
Options=nfsvers=3,hard,nolock,rw,noatime
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
[Unit]
Description=Deep health check and self-heal for permanent NFS mounts (immich/calibre/ocis)
[Service]
Type=oneshot
ExecStart=/usr/local/bin/nfs-mount-heal.sh
@@ -0,0 +1,95 @@
#!/bin/bash
# Deep health check + self-heal for permanent NFS mounts backing
# immich, calibre, ocis, and audiobookshelf.
#
# A plain `ls` on a mount root can succeed even when nested file
# handles inside it are stale (ESTALE / errno -116) -- that's the
# failure mode that broke immich on 2026-08-25 and is documented as
# a recurring issue for calibre-import in calibre/MOUNT-HISTORY.md.
# This does an actual nested read; on failure it forces a full
# remount of the NFS mount unit, which triggers the matching
# <container>-mount-ready.service (BindsTo) to restart the container
# automatically.
set -uo pipefail
source /home/poprhythm/docker-infrastructure/.credentials
# mount:unit:testdir:container
CHECKS=(
"/mnt/nas_family:mnt-nas_family.mount:/mnt/nas_family/immich-library/thumbs:immich_server"
"/mnt/nas_books:mnt-nas_books.mount:/mnt/nas_books/calibre-import:calibre"
"/mnt/nas_owncloud:mnt-nas_owncloud.mount:/mnt/nas_owncloud:ocis"
"/mnt/nas_audiobooks:mnt-nas_audiobooks.mount:/mnt/nas_audiobooks:audiobookshelf"
)
alert() {
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" -d text="$1" > /dev/null 2>&1
}
LAST_ERROR=""
healthy() {
local testdir="$1"
local f rc errfile
errfile=$(mktemp)
# No -maxdepth: -quit stops at the first match via depth-first search,
# so this is fast regardless of tree depth. A maxdepth that's too
# shallow for the actual file depth (e.g. immich's thumbs/<uuid>/XX/YY/
# file is 4 levels deep) makes find exhaust a huge fan-out with no
# match and time out -- a false positive, not real staleness (hit in
# production 2026-08-25, 3 spurious remounts in 15 min).
f=$(timeout 10 find "$testdir" -type f -print -quit 2>"$errfile")
rc=$?
if [ $rc -ne 0 ] || [ -z "$f" ]; then
LAST_ERROR="find exit=${rc}: $(cat "$errfile")"
rm -f "$errfile"
return 1
fi
# head -c, not cat: this only needs to prove the file handle/NFS path is
# alive, not fully transfer the file. Reading the whole thing was fine for
# small marker files (immich's 13-byte .immich, owncloud's 0-byte
# .migrations.lock) but nas_audiobooks has no such marker, so `find`
# legitimately picks a real audiobook - discovered 2026-09-18 to be 2.9GB,
# which a full `cat` can never finish inside a 10s timeout regardless of
# mount health. That was generating a "stale" verdict (cat exit=124,
# timeout) on a mount that was actually fine post the automount fix -
# this bug, not a real NFS issue, was the second flapping source.
timeout 10 head -c 65536 "$f" > /dev/null 2>"$errfile"
rc=$?
if [ $rc -ne 0 ]; then
LAST_ERROR="head exit=${rc} on '${f}': $(cat "$errfile")"
rm -f "$errfile"
return 1
fi
rm -f "$errfile"
return 0
}
for entry in "${CHECKS[@]}"; do
IFS=':' read -r mount unit testdir container <<< "$entry"
healthy "$testdir" && continue
# Captured so we log/alert the actual syscall error (stale handle vs
# timeout vs something else) instead of guessing - added 2026-09-18
# after nas_audiobooks kept flapping with no corroborating error on
# the NAS side even after fixing its automount-vs-permanent-mount bug.
first_error="$LAST_ERROR"
logger -t nfs-mount-heal "${mount} unhealthy, remounting ${unit}: ${first_error}"
if timeout 30 systemctl restart "$unit"; then
sleep 3
if healthy "$testdir"; then
alert "🔧 NFS self-heal: ${mount} went stale (${first_error}), remounted ${unit} and restarted ${container} automatically. All clear."
else
alert "⚠️ NFS self-heal: ${mount} was stale (${first_error}), remounted ${unit} and restarted ${container}, but it's still failing (${LAST_ERROR}). Needs a look."
fi
else
alert "❗ NFS self-heal FAILED: ${mount} is unhealthy (${first_error}) and systemctl restart ${unit} failed. Manual intervention needed."
fi
done
@@ -0,0 +1,9 @@
[Unit]
Description=Run NFS mount self-heal check every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
@@ -0,0 +1,13 @@
[Unit]
Description=Restart ocis after NAS owncloud mount is ready
After=mnt-nas_owncloud.mount
Requires=mnt-nas_owncloud.mount
BindsTo=mnt-nas_owncloud.mount
[Service]
Type=oneshot
ExecStart=/usr/bin/docker restart ocis
RemainAfterExit=yes
[Install]
WantedBy=mnt-nas_owncloud.mount
+30
View File
@@ -0,0 +1,30 @@
services:
technitium:
container_name: technitium
hostname: dns-container
image: docker.io/technitium/dns-server:latest
restart: unless-stopped
environment:
- DNS_SERVER_DOMAIN=dns-container
- DNS_SERVER_LOG_FOLDER_PATH=/var/log/technitium/dns
# Required for clustering: HTTPS admin console, used for node-to-node
# config sync and TLS join. Must NOT be proxied through nginx-proxy-manager
# (TLS must terminate at the node itself for DANE-EE cluster auth).
- DNS_SERVER_WEB_SERVICE_ENABLE_HTTPS=true
- DNS_SERVER_WEB_SERVICE_USE_SELF_SIGNED_CERT=true
ports:
- "5380:5380/tcp" # DNS web console (HTTP) - fronted by NPM
- "53443:53443/tcp" # DNS web console (HTTPS) - cluster node-to-node sync, do not proxy
- "53:53/udp" # DNS service
- "53:53/tcp" # DNS service
volumes:
- /srv/technitium/config:/etc/dns
- /srv/technitium/logs:/var/log/technitium/dns
sysctls:
- net.ipv4.ip_local_port_range=1024 65535
networks:
- npm-network
networks:
npm-network:
external: true
+9 -5
View File
@@ -53,13 +53,17 @@ services:
- nas_media:/data - nas_media:/data
volumes: volumes:
# External volume; actual definition lives in `docker volume create` (see
# NAS-CONNECTION-STRATEGIES.md). Current opts (fixed 2026-09-16 after a
# host reboot broke this): type=nfs, vers=3 (not 4 - Docker's local volume
# driver calls mount(2) directly rather than through the mount.nfs
# userspace helper, which fails with "protocol not supported" for
# nfsvers=4 on current nfs-utils/kernel), device=192.168.1.192:/mnt/user/media
# (host must be in the device string itself now, not split into a
# separate addr= option - nfs-utils 2.6.4 on Ubuntu 24.04 no longer
# accepts the old ":/path" + addr= form).
nas_media: nas_media:
external: true external: true
# driver: local
# driver_opts:
# type: nfs
# o: "addr=192.168.1.192,rw,nolock,soft"
# device: ":/mnt/user/media"
networks: networks:
default: default:
+29
View File
@@ -0,0 +1,29 @@
services:
tronbyt:
container_name: tronbyt
image: ghcr.io/tronbyt/server:2 # latest v2.x.x release tag
restart: unless-stopped
init: true
ports:
- "8001:8000" # host 8000 is taken by portainer
volumes:
- "/etc/localtime:/etc/localtime:ro"
- /srv/tronbyt-data:/app/data
environment:
- PUID=1000
- PGID=1000
# - SYSTEM_APPS_REPO=https://github.com/tronbyt/apps.git
- ENABLE_USER_REGISTRATION=false
# - SINGLE_USER_AUTO_LOGIN=true
# - GITHUB_TOKEN=${TRONBYT_GITHUB_TOKEN}
healthcheck:
test: ["CMD", "/app/tronbyt-server", "health"]
interval: 1m30s
timeout: 10s
retries: 3
start_period: 10s
networks:
default:
external:
name: npm-network