diff --git a/deploy/retire-jellyfin-library.py b/deploy/retire-jellyfin-library.py new file mode 100755 index 0000000..98fb6bf --- /dev/null +++ b/deploy/retire-jellyfin-library.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Retire the old *YouTube* Jellyfin library and rename ytstream's to take its name. + + sudo -u susan /opt/ytstream/deploy/retire-jellyfin-library.py # show the plan + sudo -u susan /opt/ytstream/deploy/retire-jellyfin-library.py --yes # do it + +plan.md §12 step 3. This is the one decommission step that throws something away: +removing a library discards Jellyfin's own state for those items — watch history, +resume positions, favourites. The *files* are untouched, and re-adding the library +at the same path returns the same ItemId and reuses the old items (measured in the +PoC), so the loss is bounded, but it is a loss. + +Libraries are matched by PATH, never by name, and the name to delete is read back +from the API rather than assumed. Deleting by a guessed name is how you remove the +wrong library: the endpoint takes a name, matches loosely on some versions, and +"YouTube" is a prefix of "YouTube (stream)". +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import sys +import urllib.error +import urllib.parse +import urllib.request + +BASE = "http://127.0.0.1:8096" +DB = "/var/lib/ytstream/ytstream.db" + +OLD_PATH = "/disks/Plex/YouTube" # youtube-automate's downloads +NEW_PATH = "/disks/Plex/_ytstream" # ytstream's .strm tree +FINAL_NAME = "YouTube" + + +def api_key() -> str: + try: + with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn: + row = conn.execute( + "SELECT value FROM setting WHERE key='jellyfin_api_key'").fetchone() + except sqlite3.Error as exc: + sys.exit(f"cannot read {DB}: {exc}\nRun me as susan or root.") + if not row or not row[0]: + sys.exit("no jellyfin_api_key in the settings table") + return row[0] + + +def request(key: str, method: str, path: str, params: dict | None = None): + url = BASE + path + if params: + url += "?" + urllib.parse.urlencode(params) + req = urllib.request.Request( + url, method=method, headers={"X-Emby-Token": key, "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=60) as response: + body = response.read() + except urllib.error.HTTPError as exc: + sys.exit(f"{method} {path} -> HTTP {exc.code}: {exc.read()[:200]!r}") + except OSError as exc: + sys.exit(f"{method} {path} -> {exc}\nIs Jellyfin running?") + return json.loads(body) if body else None + + +def folders(key: str) -> list[dict]: + return request(key, "GET", "/Library/VirtualFolders") or [] + + +def by_path(all_folders: list[dict], target: str) -> dict | None: + target = target.rstrip("/") + for folder in all_folders: + for location in folder.get("Locations") or []: + if str(location).rstrip("/") == target: + return folder + return None + + +def episode_count(key: str, library: dict) -> int: + result = request(key, "GET", "/Items", { + "parentId": library["ItemId"], + "includeItemTypes": "Episode", + "recursive": "true", + "limit": "0", + }) + return (result or {}).get("TotalRecordCount", 0) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--yes", action="store_true", help="actually make the changes") + args = ap.parse_args() + + key = api_key() + current = folders(key) + old = by_path(current, OLD_PATH) + new = by_path(current, NEW_PATH) + + if new is None: + sys.exit(f"no Jellyfin library covers {NEW_PATH}. Refusing to touch anything " + f"-- retiring the old library without a working new one would leave " + f"no YouTube library at all.") + + # A healthy replacement is the whole precondition. An empty new library means + # the scan never ran or the tree is unreadable, and removing the old one then + # would leave nothing to watch. + episodes = episode_count(key, new) + print(f"ytstream library {new['Name']!r} at {NEW_PATH}: {episodes} episodes") + if episodes == 0: + sys.exit("the ytstream library has 0 episodes -- fix that first " + "(`ytstream materialise --all`, then a Jellyfin scan)") + + if old is None: + print(f"old library none found at {OLD_PATH} (already retired)") + else: + print(f"old library {old['Name']!r} at {OLD_PATH}: " + f"{episode_count(key, old)} episodes") + + renaming = new["Name"] != FINAL_NAME + print() + print("Planned changes:") + if old is not None: + print(f" 1. DELETE library {old['Name']!r} (files in {OLD_PATH} untouched)") + if renaming: + print(f" {'2' if old is not None else '1'}. RENAME {new['Name']!r} -> {FINAL_NAME!r}") + if old is None and not renaming: + print(" nothing to do.") + return + print() + print("Jellyfin's watch history and resume positions for the deleted library go") + print("with it. The video files do not.") + + if not args.yes: + print() + print("Dry run. Re-run with --yes to apply.") + return + + if old is not None: + # Name read back from the API, not guessed. refreshLibrary=false: a scan + # here would be pointless work and, on the ytstream side, unwanted traffic. + request(key, "DELETE", "/Library/VirtualFolders", + {"name": old["Name"], "refreshLibrary": "false"}) + print(f"==> deleted {old['Name']!r}") + + if renaming: + request(key, "POST", "/Library/VirtualFolders/Name", + {"id": new["ItemId"], "newName": FINAL_NAME}) + print(f"==> renamed to {FINAL_NAME!r}") + + after = folders(key) + print() + print("Now:") + for folder in after: + locations = folder.get("Locations") or [] + if any(str(p).rstrip("/") in (OLD_PATH, NEW_PATH) for p in locations): + print(f" {folder['Name']!r} {locations}") + if by_path(after, OLD_PATH) is None and old is not None: + print(f"\n{OLD_PATH} is no longer a library. The files are still there:") + print(f" rm -rf {OLD_PATH} # when you are ready") + + +if __name__ == "__main__": + main() diff --git a/plan.md b/plan.md index 84c24c2..6cdda48 100644 --- a/plan.md +++ b/plan.md @@ -1201,3 +1201,27 @@ Not scripted, because each destroys something: and the constant can stay as it is. * **`/disks/Plex/YouTube`** (§12.6), 2.0 GB. * **`/opt/youtube-automate`, its repo, `subs.db`, `specs.md`** — keep (§12.7). + +### §12.3 is scripted now, and §12.7 changed + +`deploy/retire-jellyfin-library.py` does the Jellyfin step. It matches libraries by +**path, never by name**, and reads the name to delete back from the API instead of +assuming it — the delete endpoint takes a name, matches loosely on some versions, +and `YouTube` is a prefix of `YouTube (stream)`. It refuses to retire the old +library unless ytstream's has episodes, because doing it with a broken replacement +leaves no YouTube library at all. Dry run by default; `--yes` applies. + +What it costs, stated in the script itself: Jellyfin's watch history and resume +positions for the deleted library go with it. The files do not. + +**§12.7 revised — `/opt/youtube-automate` can go after all.** Verified 2026-08-13: +the working tree is clean, everything is pushed to +`/disks/git-repos/youtube-automate.git` (612 KB), and both `specs.md` and +`specs.handover-original.md` are tracked, so the reference material survives in the +bare repo. Nothing in ytstream's code references the old tree — only comments and +`decommission.sh`, which names the *service*. + +Still worth keeping out of `rm`: `/var/lib/youtube-automate` (170 MB) holds the old +venv and `subs.db`, and `subs.db` is *not* in the repo — it is state, not code. It is +already copied to `/var/lib/ytstream/youtube-automate-subs.db.archived-20260813`, so +that directory is now safe to delete too, just not before checking that copy exists.