Compare commits
46
Commits
7c0340519c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cda9027a | ||
|
|
39fdc4276f | ||
|
|
8a983fe307 | ||
|
|
c95fdef0e5 | ||
|
|
a0921dd387 | ||
|
|
d397938c18 | ||
|
|
2ce5a1c9a4 | ||
|
|
31f09721cd | ||
|
|
435cb931b6 | ||
|
|
b7bee3e02f | ||
|
|
9b26006597 | ||
|
|
ad0bfb8146 | ||
|
|
cd127a23e0 | ||
|
|
b2e7fcf451 | ||
|
|
e4115dfda4 | ||
|
|
4653e91f61 | ||
|
|
ef77443e49 | ||
|
|
d493b60c8b | ||
|
|
7ec3dca180 | ||
|
|
4a311301e2 | ||
|
|
ba624f33a1 | ||
|
|
7531bd698b | ||
|
|
7c65401b2c | ||
|
|
86b1610e05 | ||
|
|
ae8a929a92 | ||
|
|
5671b67fa5 | ||
|
|
a54f11ccbc | ||
|
|
6e93ca6c39 | ||
|
|
49e47a32bf | ||
|
|
027108b492 | ||
|
|
f2ac2e5c53 | ||
|
|
fddd462ff3 | ||
|
|
2b65279673 | ||
|
|
34a45cba76 | ||
|
|
718f25ca6b | ||
|
|
4d0bcc9042 | ||
|
|
bed53e6e06 | ||
|
|
f82a76c3bb | ||
|
|
65d80c5c0e | ||
|
|
7e06ce0f93 | ||
|
|
50a5a48ed4 | ||
|
|
42a31b6a44 | ||
|
|
d302060e4b | ||
|
|
ab73eb2ebb | ||
|
|
12823249aa | ||
|
|
10cc1c823b |
@@ -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.
|
||||
Executable
+252
@@ -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()
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -8,8 +8,8 @@ Summary of how each service connects to the unRAID NAS (192.168.1.192 / 192.168.
|
||||
|
||||
| Strategy | Services | Reliability | Notes |
|
||||
|----------|----------|-------------|-------|
|
||||
| Docker NFS named volume | Plex, qBittorrent, Audiobookshelf | ⚠️ Fragile | Soft mount; stale handles on drive spin-down |
|
||||
| systemd permanent mount + bind | Calibre (import), oCIS, Immich | ✅ Solid | Hard NFS; permanent; no idle cycling |
|
||||
| Docker NFS named volume | Plex, qBittorrent | ⚠️ Fragile | Soft mount; stale handles on drive spin-down |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
### Audiobookshelf
|
||||
- **Strategy**: Docker NFS named volume
|
||||
- **NAS path**: `192.168.1.192:/mnt/user/media/audiobooks`
|
||||
- **Strategy**: systemd permanent NFS mount + Docker bind mount (fixed 2026-09-17, was previously on `mnt-nas_audiobooks.automount`)
|
||||
- **Host mount**: `/mnt/nas_audiobooks` (NFS `192.168.1.192:/mnt/user/media/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
|
||||
|
||||
---
|
||||
@@ -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_owncloud.mount` | NFS permanent | `:/mnt/user/owncloud` | 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.automount` | CIFS automount | — | enabled |
|
||||
| `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
|
||||
|
||||
- **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 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 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'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.
|
||||
- **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`.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
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
|
||||
2. Update compose files to use bind mounts instead of the external `nas_media` volume
|
||||
3. `docker volume rm nas_media` after redeployment
|
||||
|
||||
@@ -10,6 +10,11 @@ services:
|
||||
image: falcosecurity/falco:0.44.1
|
||||
container_name: falco
|
||||
restart: always
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
cap_drop:
|
||||
- all
|
||||
cap_add:
|
||||
|
||||
@@ -15,11 +15,20 @@
|
||||
# removed.
|
||||
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
|
||||
desc: Outbound connection to a TCP port commonly used by cryptomining pools.
|
||||
condition: >
|
||||
outbound and evt.type in (connect, sendto, sendmsg)
|
||||
and fd.rport in (miner_pool_ports)
|
||||
and not (fd.rip in ("127.0.0.1", "::1"))
|
||||
output: >
|
||||
Outbound connection to common miner-pool port
|
||||
(user=%user.name command=%proc.cmdline connection=%fd.name
|
||||
|
||||
@@ -15,10 +15,17 @@
|
||||
# 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
|
||||
condition: >
|
||||
(proc.name = pg_isready)
|
||||
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
|
||||
# ldconfig-refresh hook, which runs via memfd_create by design every time
|
||||
@@ -65,10 +72,28 @@
|
||||
# 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,
|
||||
@@ -78,3 +103,11 @@
|
||||
# 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]
|
||||
|
||||
@@ -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
|
||||
@@ -66,26 +66,46 @@ services:
|
||||
# restart: unless-stopped
|
||||
# mem_limit: 2g
|
||||
|
||||
create:
|
||||
# create:
|
||||
# image: itzg/minecraft-server:latest
|
||||
# container_name: minecraft-create
|
||||
# ports:
|
||||
# - "55567:25565"
|
||||
# environment:
|
||||
# EULA: "true"
|
||||
# TYPE: "AUTO_CURSEFORGE"
|
||||
# CF_API_KEY: "${CF_API_KEY}"
|
||||
# CF_SLUG: "create-olympicjumperl"
|
||||
# CF_MODPACK_ZIP: "/modpack.zip"
|
||||
# SERVER_NAME: "Create SMP"
|
||||
# SEED: "-1307549829135522678"
|
||||
# MODE: "survival"
|
||||
# OPS: "Jimcognito,OlympicJumperL"
|
||||
# ENFORCE_SECURE_PROFILE: "false"
|
||||
# MEMORY: "4G"
|
||||
# volumes:
|
||||
# - /srv/minecraft-create:/data
|
||||
# - /mnt/nas_projects/Create-OlympicJumperL-2026-08-20.zip:/modpack.zip:ro
|
||||
# stdin_open: true
|
||||
# tty: true
|
||||
# restart: unless-stopped
|
||||
# mem_limit: 6g
|
||||
|
||||
survival:
|
||||
image: itzg/minecraft-server:latest
|
||||
container_name: minecraft-create
|
||||
container_name: minecraft-survival
|
||||
ports:
|
||||
- "55567:25565"
|
||||
environment:
|
||||
EULA: "true"
|
||||
TYPE: "AUTO_CURSEFORGE"
|
||||
CF_API_KEY: "${CF_API_KEY}"
|
||||
CF_SLUG: "create-olympicjumperl"
|
||||
CF_MODPACK_ZIP: "/modpack.zip"
|
||||
SERVER_NAME: "Create SMP"
|
||||
SEED: "-1307549829135522678"
|
||||
SERVER_NAME: "Survival SMP"
|
||||
SEED: "-1102400853764765851"
|
||||
MODE: "survival"
|
||||
OPS: "Jimcognito,OlympicJumperL"
|
||||
ENFORCE_SECURE_PROFILE: "false"
|
||||
MEMORY: "4G"
|
||||
volumes:
|
||||
- /srv/minecraft-create:/data
|
||||
- /mnt/nas_projects/Create-OlympicJumperL-2026-08-20.zip:/modpack.zip:ro
|
||||
- /srv/minecraft-survival:/data
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
image: deluan/navidrome:0.64.0
|
||||
container_name: navidrome
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ND_MUSICFOLDER=/media/music/albums
|
||||
- ND_AUTOIMPORTPLAYLISTS=false
|
||||
- ND_LOGLEVEL=info
|
||||
- ND_PORT=4533
|
||||
- TZ=America/New_York
|
||||
|
||||
@@ -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
|
||||
@@ -7,9 +7,10 @@ services:
|
||||
- obico-internal
|
||||
|
||||
obico:
|
||||
# :cuda tag attempts GPU but falls back to CPU if CUDA version mismatch
|
||||
# (libcudart.so.11.0 required; GTX 1660 SUPER with driver 590 has CUDA 12).
|
||||
# ML inference still works on CPU - adequate for a single printer.
|
||||
# :cuda tag falls back to CPU anyway on this GPU (libcudart.so.11.0
|
||||
# required; GTX 1660 SUPER with driver 590 has CUDA 12) - adequate for a
|
||||
# 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
|
||||
container_name: obico
|
||||
restart: unless-stopped
|
||||
@@ -33,13 +34,6 @@ services:
|
||||
- /srv/obico/config/model_cache:/model_cache/ml_api
|
||||
ports:
|
||||
- "3334:3334"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
networks:
|
||||
- npm-network
|
||||
- obico-internal
|
||||
|
||||
@@ -10,6 +10,20 @@ services:
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
- TZ=America/New_York
|
||||
- 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:
|
||||
- /srv/ollama:/root/.ollama
|
||||
runtime: nvidia
|
||||
|
||||
@@ -130,6 +130,61 @@ print(f'Done. ConfigHash: {hash}')
|
||||
" "$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() {
|
||||
local name="${1:-}"
|
||||
local compose_path="${2:-}"
|
||||
@@ -292,6 +347,7 @@ command="${1:-}"
|
||||
case "$command" in
|
||||
list) cmd_list ;;
|
||||
redeploy) cmd_redeploy "${2:-}" ;;
|
||||
fix-git-auth) cmd_fix_git_auth "${2:-}" ;;
|
||||
deploy) cmd_deploy "${@:2}" ;;
|
||||
get-env) cmd_get_env "${2:-}" ;;
|
||||
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 " 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 " fix-git-auth <stack-name> Re-supply git credentials when redeploy fails with 'Unable to clone'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
Executable
+105
@@ -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
|
||||
Executable
+206
@@ -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."
|
||||
Executable
+216
@@ -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
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+404
@@ -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()
|
||||
@@ -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
|
||||
@@ -53,13 +53,17 @@ services:
|
||||
- nas_media:/data
|
||||
|
||||
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:
|
||||
external: true
|
||||
# driver: local
|
||||
# driver_opts:
|
||||
# type: nfs
|
||||
# o: "addr=192.168.1.192,rw,nolock,soft"
|
||||
# device: ":/mnt/user/media"
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user