POST /Library/VirtualFolders/Name is the odd one out in that controller -- 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 10.11.4: name -> 204. Also records that renaming re-ids the library, because Jellyfin derives the ItemId from the name. ytstream is unaffected because find_library matches on path -- confirmed by a full refresh-metadata over 257 NFOs with 0 proxy requests straight after the rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
6.8 KiB
Python
Executable File
175 lines
6.8 KiB
Python
Executable File
#!/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()
|