#!/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: # `name`, NOT `id`. This endpoint is the odd one out -- most of # /Library/VirtualFolders/* takes an id, and passing one here returns a # bare "HTTP 400: Error processing request." that says nothing about why. # Verified against Jellyfin 10.11.4 on 2026-08-13: name -> 204. request(key, "POST", "/Library/VirtualFolders/Name", {"name": new["Name"], "newName": FINAL_NAME}) print(f"==> renamed {new['Name']!r} to {FINAL_NAME!r}") # A library's ItemId is derived from its name, so renaming re-ids it -- # measured, 98e74a0c… became 34f331a8…, which was the *deleted* library's # id, because that one had this name. Nothing here caches an ItemId, and # ytstream's own find_library() matches on path, so both survive it. Any # future caller that stores an ItemId will not. print(" note: the library's ItemId changed (Jellyfin derives it from " "the name)") 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()